Skip to content

Add redirect extra headers#5631

Draft
ankur22 wants to merge 21 commits intofix/extra-emit-of-request-metricsfrom
add/redirect-extra-headers
Draft

Add redirect extra headers#5631
ankur22 wants to merge 21 commits intofix/extra-emit-of-request-metricsfrom
add/redirect-extra-headers

Conversation

@ankur22
Copy link
Contributor

@ankur22 ankur22 commented Feb 9, 2026

What?

Introduces an index-based extraInfoTracker to correctly pair CDP ExtraInfo events with their corresponding Request/Response objects, replacing the previous approach that incorrectly merged extra headers across redirect chains.

It also enables and registers the handlers for the ExtraInfo which contain the raw headers.

Why?

Raw headers vs provisional headers

CDP provides two sets of headers for every network request/response:

  • Provisional headers (from responseReceived / requestWillBeSent): refined by Chrome's internal processing — may omit cookies, rewrite header names, etc.
  • Raw wire headers (from responseReceivedExtraInfo / requestWillBeSentExtraInfo): exactly what was sent/received over the network, including cookies, Host, etc.

The raw headers are what allHeaders() should return.

Two independent "channels"

These two sets of events are not synchronised. This isn't an explicit concept in the CDP protocol — there's no "channel" field in the payload. It's an observed behavioural pattern in how Chrome's internals emit events. The CDP docs confirm this:

"responseReceivedExtraInfo may be fired before or after responseReceived."
CDP docs: responseReceivedExtraInfo

"there is no guarantee whether requestWillBeSent or requestWillBeSentExtraInfo will be fired first for the same request."
CDP docs: requestWillBeSentExtraInfo

The main network stack emits requestWillBeSent, responseReceived, loadingFinished, etc. A separate layer (closer to the wire/transport) emits the ExtraInfo variants. Because they come from different parts of Chrome's architecture, they arrive independently. The only thing linking them is the shared requestId.

Redirect chains make it harder

During a redirect chain (e.g. //r/3/r/2/final), Chrome reuses the same requestId for the entire chain. Each hop produces its own pair of events on both "channels":

sequenceDiagram
    participant Browser
    participant Main as Main events
    participant Extra as ExtraInfo events

    Note over Main,Extra: requestId = "ABC123" reused for entire chain

    Browser->>Extra: requestWillBeSentExtraInfo[0] (request headers for /)
    Browser->>Extra: responseReceivedExtraInfo[0] (302 response headers from /)
    Browser->>Main: requestWillBeSent (redirectResponse for /)
    Note over Main: creates Request[0] + Response[0]

    Browser->>Extra: requestWillBeSentExtraInfo[1] (request headers for /r/3)
    Browser->>Extra: responseReceivedExtraInfo[1] (302 response headers from /r/3)
    Browser->>Main: requestWillBeSent (redirectResponse for /r/3)
    Note over Main: creates Request[1] + Response[1]

    Browser->>Extra: requestWillBeSentExtraInfo[2] (request headers for /final)
    Browser->>Main: responseReceived (200 from /final)
    Note over Main: creates Request[2] + Response[2]
    Browser->>Extra: responseReceivedExtraInfo[2] (200 response headers from /final)

    Browser->>Main: loadingFinished
Loading

Index-based pairing

Within each "channel", events for a given requestId arrive in order. So the i-th responseReceivedExtraInfo always corresponds to the i-th response for that requestId. The extraInfoTracker uses this invariant to pair by index position — the same approach Playwright uses. When either side arrives first, it queues up. As soon as both sides are available at the same index, the tracker patches the raw headers onto the Response object.

Example

k6 script
import { expect } from "https://jslib.k6.io/k6-testing/0.6.1/index.js";
import { browser } from 'k6/browser';

export const options = {
  scenarios: {
    redirectHeaders: {
      executor: 'shared-iterations',
      options: {
        browser: {
          type: 'chromium',
        },
      },
    },
  },
};

export default async function () {
  const page = await browser.newPage();

  const responses = [];
  page.on('requestfinished', async (request) => {
    const response = await request.response();
    if (!response) {
      return;
    }
    const url = response.url();
    const status = response.status();
    const headers = await response.allHeaders();
    responses.push({ url, status, headers });
    console.log(`[response] ${status} ${url}`);
    console.log('  allHeaders:', JSON.stringify(headers, null, 2));
  });

  await page.goto('http://localhost:8765/', {
    waitUntil: 'networkidle',
  });

  const body = await page.locator('body').textContent();
  expect(body).toContain('OK: redirect chain finished');

  const redirectResponses = responses.filter((r) => r.status === 302);
  expect(redirectResponses.length).toBeGreaterThanOrEqual(3);

  const finalResponse = responses.find((r) => r.url.endsWith('/final') && r.status === 200);
  expect(finalResponse).toBeDefined();
  expect(finalResponse.headers['x-redirect-step']).toEqual('final');
  expect(finalResponse.headers['x-extra-info']).toEqual('visible-in-allHeaders');

  await page.close();
}
Go server to test the change
// Redirect server for testing redirect chains and response headers (e.g. allHeaders() in k6 browser / Playwright).
//
// Run: go run .   (listens on http://localhost:8765)
//
// Endpoints:
//   - GET /           → 302 to /r/3 (start of chain)
//   - GET /r/3        → 302 to /r/2
//   - GET /r/2        → 302 to /r/1
//   - GET /r/1        → 302 to /final
//   - GET /final      → 200 OK with body
//
// Each response sets distinct headers so you can see which step you're on in allHeaders():
//
//	X-Redirect-Step, X-Response-Path, X-Server-Time, X-Request-Id (per request)
package main

import (
	"fmt"
	"log"
	"net/http"
	"time"
)

const port = 8765

func main() {
	mux := http.NewServeMux()

	// Return 404 for favicon to avoid the catch-all triggering a second redirect chain.
	mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, _ *http.Request) {
		http.NotFound(w, nil)
	})

	// Start of chain
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		setExtraHeaders(w, "/", 0)
		http.Redirect(w, r, "/r/3", http.StatusFound)
	})

	// Redirect steps: /r/3 → /r/2 → /r/1 → /final
	mux.HandleFunc("/r/3", redirectHandler("/r/2", 3))
	mux.HandleFunc("/r/2", redirectHandler("/r/1", 2))
	mux.HandleFunc("/r/1", redirectHandler("/final", 1))

	// Final response
	mux.HandleFunc("/final", func(w http.ResponseWriter, r *http.Request) {
		setExtraHeaders(w, "/final", -1)
		w.Header().Set("Content-Type", "text/plain")
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("OK: redirect chain finished\n"))
	})

	addr := fmt.Sprintf(":%d", port)
	log.Printf("Redirect server listening on http://localhost%s", addr)
	log.Printf("  Try: http://localhost%s then inspect allHeaders() on each response in the chain.", addr)
	if err := http.ListenAndServe(addr, mux); err != nil {
		log.Fatal(err)
	}
}

func redirectHandler(target string, step int) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		setExtraHeaders(w, r.URL.Path, step)
		http.Redirect(w, r, target, http.StatusFound)
	}
}

func setExtraHeaders(w http.ResponseWriter, path string, step int) {
	stepStr := fmt.Sprintf("%d", step)
	if step < 0 {
		stepStr = "final"
	}
	w.Header().Set("X-Redirect-Step", stepStr)
	w.Header().Set("X-Response-Path", path)
	w.Header().Set("X-Server-Time", time.Now().UTC().Format(time.RFC3339Nano))
	w.Header().Set("X-Extra-Info", "visible-in-allHeaders")
}

Checklist

  • I have performed a self-review of my code.
  • I have commented on my code, particularly in hard-to-understand areas.
  • I have added tests for my changes.
  • I have run linter and tests locally (make check) and all pass.

Checklist: Documentation (only for k6 maintainers and if relevant)

Please do not merge this PR until the following items are filled out.

  • I have added the correct milestone and labels to the PR.
  • I have updated the release notes: link
  • I have updated or added an issue to the k6-documentation: grafana/k6-docs#NUMBER if applicable
  • I have updated or added an issue to the TypeScript definitions: grafana/k6-DefinitelyTyped#NUMBER if applicable

Related PR(s)/Issue(s)

Closes: #4291

This function will be used to parse the incoming headers from extraInfo
Registers our interest to receive extraInfo data async from chromium.
...extra headers from extraInfo asynchronously.
This is the key to being able to track all the extraInfo headers that
arrive for each of the redirect requests/responses. The initial request
will have async extraInfo headers under the same requestID. Subsequent
redirects will also have the same requestID. So extraInfoTracker helps
to keep track of each redirect's extraInfo header.
Since it already tracks the requests, this feels like a natural place
for the extraInfoTracker to live.
...request and response. This will be matched up with the corresponding
request or response. When there's a match the request/response will
have its extra headers set.
This covers two cases:
1. A non-redirecting request, so only processing of a request.
2. A redirecting request which will process a request and a response.
This now takes into account whether there are extra headers before
calculating the headers size.
...extra headers which may or may not be present.
@ankur22 ankur22 force-pushed the fix/extra-emit-of-request-metrics branch from fca34be to 7d765fe Compare February 9, 2026 23:17
@ankur22 ankur22 temporarily deployed to azure-trusted-signing February 9, 2026 23:23 — with GitHub Actions Inactive
@ankur22 ankur22 temporarily deployed to azure-trusted-signing February 9, 2026 23:25 — with GitHub Actions Inactive
@ankur22 ankur22 added this to the v1.7.0 milestone Feb 10, 2026
Since the frame URL is updated asynchronously, we cannot rely on a
deterministic way of asserting on it. It is being ignored for now.
@ankur22 ankur22 force-pushed the add/redirect-extra-headers branch from 1951bd5 to e7fc161 Compare February 10, 2026 10:36
@ankur22 ankur22 temporarily deployed to azure-trusted-signing February 10, 2026 10:42 — with GitHub Actions Inactive
@ankur22 ankur22 temporarily deployed to azure-trusted-signing February 10, 2026 10:44 — with GitHub Actions Inactive
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant