Skip to content

Send instance name as x-api-key to the OTLP relay#316

Open
archandatta wants to merge 12 commits into
mainfrom
archand/kernel-1612/otlp-exporter-contract
Open

Send instance name as x-api-key to the OTLP relay#316
archandatta wants to merge 12 commits into
mainfrom
archand/kernel-1612/otlp-exporter-contract

Conversation

@archandatta

@archandatta archandatta commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Keeps the in-VM OTLP exporter on the instance JWT (Authorization: Bearer <KERNEL_INSTANCE_JWT>) and resolves it per request so forked VMs export with a fresh credential. The relay (kernel/kernel#2763) is being changed to verify the JWT signature/expiry and read instance_name (+ metro) from its claims, then keep the existing active-session + otlp-binding lookups for authorization — claims say who's calling, the DB says what they're allowed to do.

Companion to the metro-api relay PR (kernel/kernel#2763). Wire contract: Authorization: Bearer <instance_jwt> + POST to BTEL_OTLP_PATH (set to /otlp-relay/v1/logs by provisioning when pointing a VM at the relay).

Why

The instance JWT already carries instance_name and metro in its claims, so the relay gets everything a bare instance name would give it today, plus room for session_id/org_id/etc. later. The relay isn't deployed yet, so this is the cheapest moment to set the contract we actually want rather than standardizing further on bare instance names. Long term, capmonster/hcaptcha/extensionfeatures can migrate onto the same verified-JWT model.

Fork-identity credential refresh

The exporter previously captured the credential once at process start. In fork-identity-wait mode kernel-images-api starts before identity applies and is never restarted, and the platform delivers a fresh KERNEL_INSTANCE_JWT in the applied identity payload — so a boot-time snapshot would export with a stale/empty credential forever. This PR resolves the JWT per request instead:

  • instanceJWTProvider reads kernel_instance_jwt from the applied fork-identity payload (only once the applied marker is present, to avoid a torn mid-apply read), falling back to the boot env JWT; result cached briefly (3s) to avoid a filesystem read per export. Outside fork mode it returns the boot JWT unchanged.
  • The exporter installs a RoundTripper (via otlploghttp.WithHTTPClient) that sets the bearer header from the provider on each request; an empty token sends no header.

Changes

  • server/cmd/api/main.go: build the exporter with an AuthTokenFunc backed by instanceJWTProvider instead of a static header map.
  • server/cmd/api/otlp_credential.go: the per-request JWT provider (fork-payload read + fallback + TTL cache).
  • server/lib/events/otlpstorage.go: OTLPConfig.AuthTokenFunc + bearerRoundTripper.
  • server/cmd/config/config.go: unchanged from main (the InstanceJWT field stays).

Test plan

  • go build ./..., go vet, and go test ./cmd/config ./cmd/api/... ./lib/events green (incl. -race).
  • Unit: TestInstanceJWTProvider (non-fork boot / fork-before-apply fallback / fork-after-apply fresh), TestOTLPStorageWriter_RefreshesAuthToken (token change mid-run reflected on later exports), reverted TestOTLPStorageWriter_ExportsEvents to assert the bearer header.
  • Live e2e (local): unbound fork exports with no bearer, then after the fork-identity payload is applied the relay sees Bearer <fresh jwt> with no restart.
  • Full relay round-trip validated separately once kernel/kernel#2763 is deployed.

Notes

  • Deploy ordering: this and the relay PR set the wire contract together but neither blocks the other's review. Before a forked session exports end-to-end, the relay must verify the JWT (metro-api gets the JWT secret) and this exporter must send + refresh it. The relay isn't live yet, so there's no rollout ordering hazard.
  • Resource identity on forks: the OTLP Resource attributes (kernel.instance_name/metro) are also resolved from the applied fork-identity payload (via InstanceNameFunc/MetroFunc read at exporter-build time), so a forked VM's telemetry is labeled with its own instance, not the parent/empty boot value. Covered by the fork-identity e2e.
  • Future direction (not in this PR): re-mint the JWT at claim time with session_id/org_id claims via a generalized identity-update channel, and migrate the sibling relays onto the same dynamic-credential model.

archandatta and others added 6 commits July 8, 2026 12:41
Adds an optional in-VM OTLP/HTTP export sink that converts browser telemetry
events into OTLP log records and forwards them to a configured endpoint,
alongside the existing S2 sink. Gated on OTLP_RELAY_ENDPOINT; no behavior
change when unset.

- Converter maps each telemetry envelope to an OTLP log record: event type to
  EventName (also mirrored to kernel.event.type for backends that drop it),
  structured JSON body, derived severity, and kernel.* attributes. Network and
  console fields are promoted to semantic-convention attributes. Screenshot and
  monitor categories are excluded from export.
- Sink mirrors S2StorageWriter: an independent ring reader feeds records through
  the OTel log SDK (otlploghttp + batch processor), running independently of the
  S2 sink. Export failures surface via a logging exporter wrapper.
- Config and startup wiring, plus a dev collector config for local testing.
Rename export env vars from OTLP_RELAY_* to BTEL_OTLP_* so they don't
collide with the standard OTEL_* vars that configure the API server's own
telemetry, and drop the metro-api-specific "relay" naming (dev points at a
collector). Rename the platform-identity config fields to InstanceJWT /
InstanceName / MetroName since they aren't OTLP-specific. Default export
path is now the standard /v1/logs.

Make sink startup non-fatal: an optional best-effort exporter failing to
start must not take down the browser, so log and continue instead of
os.Exit(1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The OTel log SDK reports records dropped under sustained backpressure via
its global logger at logr V(1). That logger is a no-op until wired, and even
once wired it renders just below slog Info, so a default Info handler would
still swallow it. Route the SDK global logger to a handler that admits that
level when export is enabled, so drops surface instead of being silently
discarded (which looked identical to a healthy idle sink).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TestLoad's expected structs didn't account for the OTLPPath and
OTLPServiceName defaults that Load applies, so it failed once those
defaults were in place. Assert the applied defaults.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The batch processor caps records per export by count (200), not bytes.
With the 1MB per-envelope publish ceiling, a full batch of large records
could reach ~200MB and be rejected by the collector/relay, and the SDK
drops rejected records without retry.

Split each export in the loggingExporter into sub-requests under a byte
budget (4MiB) so a batch of large records can't exceed the target's HTTP
body limit; a single record (<=1MB) always fits in one sub-request. Adds
a chunk-splitting unit test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The metro-api OTLP relay authenticates a VM by its instance name checked
against active sessions (mirroring the capmonster/hcaptcha relays), so
send INST_NAME as x-api-key instead of the instance JWT as a bearer
token. Drop the now-unused KERNEL_INSTANCE_JWT exporter config (the env
still exists for the envoy wrapper; the exporter just no longer reads it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@archandatta
archandatta marked this pull request as ready for review July 15, 2026 16:51
Base automatically changed from archand/kernel-1213/otel-export to main July 15, 2026 18:35
@pulumi

pulumi Bot commented Jul 15, 2026

Copy link
Copy Markdown

⚠️ Pulumi could not deploy preview(s) for this pull request because GitHub reports it is not mergeable (mergeable state: dirty). This usually means the branch has merge conflicts with its base branch. Resolve the conflicts and push a new commit to retry.

# Conflicts:
#	server/cmd/api/main.go
#	server/cmd/config/config.go
Comment thread server/cmd/api/main.go Outdated
# Conflicts:
#	server/cmd/config/config.go
Comment thread server/cmd/config/config.go
The exporter round-trip test still asserted an Authorization: Bearer
header; the relay authenticates by instance name sent as x-api-key, so
the fixture now sends and asserts that header to document the real wire
contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@archandatta
archandatta requested review from Sayan- and rgarcia July 23, 2026 17:26

@rgarcia rgarcia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to go the other way here: keep the exporter on the instance JWT and correct the relay to verify it — the relay isn't deployed yet, so this pair of PRs is the cheapest moment to set the contract we actually want, rather than standardizing further on bare instance names. the JWT already carries instance_name (+ metro) in its claims, so the relay gets everything x-api-key gives it today, plus a place to put more later.

what's blocking JWT auth today — small list:

  1. relay side (kernel/kernel#2763): verify the token, keep the lookups for authorization. plumb the JWT secret into metro-api, validate signature/expiry, take instance_name from the claims, and then the existing active-session + otlp-binding lookups stay exactly as they are — claims tell the relay who's calling, the DB tells it what they're allowed to do.
  2. this PR: keep sending the JWT (Authorization: Bearer <KERNEL_INSTANCE_JWT>), i.e. roughly revert to the pre-PR header and keep the InstanceJWT config field.
  3. fork-identity-wait must refresh the exporter's credential. the fork identity payload already delivers a new KERNEL_INSTANCE_JWT, but kernel-images-api starts before identity applies and is never restarted, and the exporter captures config once at process start — so forked VMs export with a stale/empty credential forever (true for x-api-key too, so this needs fixing regardless of which credential wins). the exporter needs to read the JWT dynamically (from the applied identity payload) rather than from env-at-boot.

future direction, briefly: re-mint the JWT at claim time with session_id / org_id / other useful instance claims, delivered through a generalized identity-update channel (the fork-identity endpoint grown into the every-VM claim-time push), and migrate capmonster/hcaptcha/extensionfeatures onto the same model. none of that blocks v1 — the boot-time JWT with the instance name claim is enough for the relay today.

cc @sjmiller609

Per review, the relay (kernel/kernel#2763) is being changed to verify the
instance JWT rather than trust a bare instance name, so the exporter keeps
sending Authorization: Bearer <KERNEL_INSTANCE_JWT>. This reverts the
x-api-key switch and restores the InstanceJWT config field.

The JWT is now resolved per request instead of frozen at process start. In
fork-identity-wait mode the platform delivers a fresh KERNEL_INSTANCE_JWT in
the applied identity payload after kernel-images-api has already started (and
it is never restarted), so a boot-time snapshot would export with a stale or
empty credential forever. A new instanceJWTProvider reads the JWT from the
applied fork-identity payload (falling back to the boot env), and the exporter
installs a RoundTripper that sets the bearer header from it on each request.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread server/cmd/api/main.go Outdated
URLPath: config.OTLPPath,
Insecure: config.OTLPInsecure,
Headers: headers,
AuthTokenFunc: jwtProvider.Token,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auth header mismatches relay contract

High Severity

The OTLP exporter sends an Authorization: Bearer token, but the relay expects an x-api-key header with the instance name. This authentication mismatch causes OTLP exports to fail, preventing session telemetry from landing.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a6d280d. Configure here.

Comment thread server/cmd/api/main.go Outdated
Comment thread server/cmd/api/otlp_credential.go Outdated
Comment thread server/cmd/api/otlp_credential.go
Two container e2e tests against a mock OTLP collector (the S2 sink had e2e
coverage; OTLP had none):

- TestOTLPExport: enables export via the telemetry API and asserts records
  land carrying the instance JWT as a bearer token plus the instance-name
  resource attribute.
- TestOTLPExportForkIdentityRefresh: boots in fork-identity-wait mode with an
  empty boot JWT, applies a fresh KERNEL_INSTANCE_JWT via the real
  /internal/fork-identity endpoint, and asserts exports pick up that JWT with
  no restart.

Both skip when docker is unavailable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
forkidentity.PayloadFile = payload
forkidentity.AppliedFile = applied
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate fork-identity test helper

Low Severity

withForkIdentityFiles duplicates useTempForkIdentityFiles in the same main package. Both retarget forkidentity.PayloadFile / AppliedFile to a temp dir and restore them on cleanup, so future path-handling fixes can easily drift between the two helpers.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3c9a8e5. Configure here.

Addresses review follow-ups on the fork-identity credential path:

- Resource attributes (kernel.instance_name/metro) were still stamped from
  boot env, so a forked VM authenticated with the fresh JWT but labeled its
  telemetry with the parent/empty instance. Resolve them from the applied
  fork-identity payload too, via InstanceNameFunc/MetroFunc read at
  exporter-build time (export starts per session after identity applies).
- Cache only a payload-sourced (or non-fork, stable) identity: a boot fallback
  during the pre-apply window is returned uncached so the fresh identity is
  picked up on the next call rather than pinned for the cache TTL.
- Surface an invalid KERNEL_FORK_IDENTITY_WAIT instead of silently disabling
  the payload read.

The fork-identity e2e now asserts the refreshed instance_name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1d5acf1. Configure here.

metro := cfg.Metro
if cfg.MetroFunc != nil {
metro = cfg.MetroFunc()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resource identity frozen after export start

Low Severity

InstanceNameFunc and MetroFunc are evaluated once when the exporter is built at Start, while AuthTokenFunc refreshes on every request. If OTLP export is enabled before fork identity applies and left running, the bearer JWT updates after apply but kernel.instance_name and kernel.metro stay at the empty or boot values for that export session. The API is already reachable in fork-wait mode before apply, so that ordering is possible.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1d5acf1. Configure here.

@archandatta
archandatta requested a review from rgarcia July 24, 2026 14:03
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.

2 participants