Skip to content

Serve browser telemetry storage through the metro-api relay - #508

Draft
Sayan- wants to merge 4 commits into
mainfrom
hypeship/btel-storage-relay-sink
Draft

Sayan- wants to merge 4 commits into
mainfrom
hypeship/btel-storage-relay-sink

Conversation

@Sayan-

@Sayan- Sayan- commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Serve browser telemetry storage through the metro-api relay instead of baking S2 credentials into VMs. This PR adds the in-VM sink.

  • When BTEL_STORAGE_PATH is set, stored telemetry is posted to the metro-api relay instead of appended to S2. Direct S2 (S2_BASIN plus S2_ACCESS_TOKEN) is unchanged and still used when the path is unset. The relay wins when both are set.

  • Each record body is json.Marshal(envelope), exactly what the direct sink appended, so S2 contents and the read path are unchanged. It is sent with the event time converted from microseconds to milliseconds.

  • The sink follows the OTLP sink's semantics:

    • A bounded queue that drops the oldest record when full, so Append never blocks the ring reader.
    • Batches flush at 200 records, 768 KiB metered, or a 100ms linger. One request is in flight at a time, so order is preserved.
    • 429, 502, 503, 504 and temporary transport errors are retried with the OTLP exporter's backoff, honouring Retry-After, for up to 30s per batch.
    • Every other outcome drops the batch and counts it, 403 and 413 included.
    • Close flushes what it can within the shutdown budget.
  • The bearer token (instance JWT) and relay host are resolved per batch, so a fork posts with its own identity to the metro-api it was handed. Host order:

    1. the identity's metro-api URL;
    2. BTEL_OTLP_ENDPOINT, with BTEL_OTLP_INSECURE picking the scheme.

    The token goes only on requests to that URL, and redirects are refused.

  • The storage controller now takes a storage constructor. The relay controller shares the S2 controller's start/stop/once-only lifecycle and its fork gate: nothing opens until S2_STREAM (or the applied fork identity's stream) resolves. The telemetry handler is unchanged.

  • Config: BTEL_STORAGE_PATH, BTEL_STORAGE_MAX_RECORDS (200), BTEL_STORAGE_MAX_BYTES (786432), BTEL_STORAGE_LINGER (100ms), BTEL_STORAGE_MAX_QUEUE (8192). When the path is set, values are validated against S2's batch limits and a relay host is required. Documented in server/README.md.

  • Metrics: kernel_btel_relay_exported_total, _dropped_total{reason}, _retries_total, _failed_total{status}, _queue_depth.

Rollout

Release this image before provisioning sets BTEL_STORAGE_PATH. Images without this sink provisioned without S2 credentials store nothing.

Validation

  • lib/events relay sink tests against httptest:

    • Batches arrive in order, and bodies match json.Marshal(envelope) byte for byte, including HTML and line-separator escapes. ts is in milliseconds.
    • Flush on record count and on metered bytes, with every batch under the budget.
    • Each of 429/502/503/504 is retried. Retry-After is honoured.
    • 400/401/403/413/500, a refused connection and a redirect are dropped and counted without retry. The redirect target is never contacted.
    • Token and host are re-read after a simulated fork identity change.
    • Drop-oldest accounting keeps the newest records.
    • Close past its deadline counts a shutdown drop.
    • Relay URL construction.
  • Controller contract tests run against both the S2 and relay controllers: lifecycle, the fork gate, and rollback when start is cancelled.

  • cmd/api/api/telemetry_test.go: the storage toggle runs against both real controllers. Storage opens once, refuses to turn off (409), and stays closed while no stream resolves.

  • Config validation cases, the metrics collector, and the relay base URL fallback.

  • e2e, run locally against a freshly built headless image:

    • TestRelayStorage: relay plus direct S2 credentials; records reach a mock relay with the instance JWT and valid envelopes.
    • TestRelayStorageForkIdentity: a fork boots with no JWT and a dead endpoint. After the identity handoff, storage reaches the handed metro-api with the fresh JWT.

    Both pass, as does the existing TestOTLPExport. Locally, testcontainers needed TESTCONTAINERS_RYUK_DISABLED=true and TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 because of this machine's DNS.

  • go test ./... outside e2e passes. One devtoolsproxy test flaked once on Chromium temp-dir cleanup and passed on rerun.

  • Not tested: a real metro-api relay and S2.

🤖 Generated with Claude Code

Sayan- and others added 4 commits November 8, 2026 04:50
The controller and writer take a constructor instead of S2 credentials,
so a second backend can share their lifecycle and fork gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When BTEL_STORAGE_PATH is set, stored telemetry is posted in batches to
the metro-api relay with the instance JWT instead of appended to S2 with
a credential baked into the VM. Direct S2 stays available when only the
S2 credentials are set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Retry temporary transport errors like the OTLP exporter, count batches
cut off by Close as shutdown drops, keep the post-deadline wait short,
and refuse a relay path with no host to reach it on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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