Skip to content

Add in-VM OTLP telemetry export sink#308

Open
archandatta wants to merge 5 commits into
mainfrom
archand/kernel-1213/otel-export
Open

Add in-VM OTLP telemetry export sink#308
archandatta wants to merge 5 commits into
mainfrom
archand/kernel-1213/otel-export

Conversation

@archandatta

@archandatta archandatta commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

  • 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 BTEL_OTLP_ENDPOINT; no behavior change when unset.
  • Converter maps each telemetry envelope to an OTLP log record: event type to EventName (mirrored to a kernel.event.type attribute for backends that drop EventName), a structured JSON body, derived severity, and kernel.* attributes. High-value network/console fields are promoted to semantic-convention attributes (http.response.status_code, url.full, http.request.method). Screenshot and monitor categories are excluded.
  • Sink mirrors S2StorageWriter: an independent ring reader feeds records through the OTel log SDK (otlploghttp + batch processor), running independently of S2.

Config (BTEL_OTLP_*)

  • BTEL_OTLP_ENDPOINT (host[:port]; presence enables the sink), BTEL_OTLP_PATH (default /v1/logs), BTEL_OTLP_INSECURE, BTEL_OTLP_SERVICE_NAME (default kernel-browser).
  • Platform identity reused for the OTLP Resource and auth header: KERNEL_INSTANCE_JWT, INST_NAME, METRO_NAME.
  • The sink is a standard OTLP/HTTP client, so the endpoint can be a local collector, a forwarding relay, or a customer's OTLP backend directly. Pointing at the metro-api relay just means setting BTEL_OTLP_PATH=/otlp-relay/v1/logs.

Review feedback addressed

  • Renamed export env vars OTLP_RELAY_* to BTEL_OTLP_* so they don't collide with the standard OTEL_* vars that configure the API server's own telemetry, and dropped the relay-specific naming (dev points at a collector).
  • Renamed the platform-identity config fields to InstanceJWT / InstanceName / MetroName (they aren't OTLP-specific).
  • Sink start failure is non-fatal: an optional best-effort exporter must not take down the browser, so it logs and continues instead of exiting.
  • Wired the OTel SDK's global logger so batch-queue drops under sustained backpressure surface in logs instead of being silently discarded.

Testing

  • Converter unit tests (field mapping, promoted attributes, structured body, severity, truncation, number coercion, JSON fallback, category exclusion), an end-to-end sink test that decodes the exported OTLP payload from a fake receiver and asserts an excluded category never lands, and a logging-exporter test asserting export failures are counted.
  • go build ./..., go vet, and the server unit suite are green.
  • Manually validated end-to-end against a local collector and a self-hosted SigNoz with real headless-Chromium CDP runs: page/network/console events land, screenshot/monitor stay excluded, and promoted attributes are queryable. Local recipe:
    docker run --rm -p 4318:4318 otel/opentelemetry-collector-contrib \
      --set=receivers.otlp.protocols.http.endpoint=0.0.0.0:4318 \
      --set=exporters.debug.verbosity=detailed \
      --set=service.pipelines.logs.receivers=[otlp] \
      --set=service.pipelines.logs.exporters=[debug]
    # then run the server with:
    #   BTEL_OTLP_ENDPOINT=127.0.0.1:4318 BTEL_OTLP_INSECURE=true
    

Scope and follow-ups

  • MVP scope: logs, best-effort delivery. Off by default. Producing telemetry for a real browser session additionally requires provisioning to set BTEL_OTLP_ENDPOINT on the VM (not yet wired), gated on a resolved destination so we don't generate work with nowhere to land.
  • Deferred (tracked separately): spans, durable / at-least-once delivery, and exporter improvements (export enable/disable API, service-crash and OOM-kill events, externalized batch knobs, event reshape for relay routing, a drop metric).

Note

Medium Risk
Adds outbound telemetry with instance JWT auth and new OTel dependencies; delivery is best-effort and misconfiguration could leak volume to a customer endpoint, but failures do not stop the browser process.

Overview
Adds an optional OTLP/HTTP export path for browser telemetry, parallel to the existing S2 sink. When BTEL_OTLP_ENDPOINT is set, the API process starts an OTLPStorageWriter on the shared event ring; unset leaves behavior unchanged.

Config uses BTEL_OTLP_* (to avoid clashing with server OTEL_*) plus existing VM identity envs (KERNEL_INSTANCE_JWT, INST_NAME, METRO_NAME) for auth headers and OTLP resource attributes. Startup failure is non-fatal (log and continue). OTel’s global logger is wired so batch-queue drops are visible under backpressure.

Conversion maps telemetry envelopes to OTLP log records: structured JSON body, coarse severity, kernel.* metadata, promoted HTTP/console attributes, and drops screenshot/monitor categories. Export uses the OTel log SDK with HTTP batching, JWT headers, and size-based request chunking (~4 MiB) plus export-failure logging.

Shutdown drains the OTLP writer after HTTP servers, mirroring S2. New unit/integration tests cover conversion, chunking, and end-to-end export to a fake receiver.

Reviewed by Cursor Bugbot for commit 23546c0. Bugbot is set up for automated code reviews on this repo. Configure here.

@socket-security

socket-security Bot commented Jul 7, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: golang golang.org/x/tools is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: ?golang/go.opentelemetry.io/proto/otlp@v1.10.0golang/github.com/s2-streamstore/s2-sdk-go@v0.16.1golang/github.com/nrednav/cuid2@v1.1.0golang/github.com/docker/go-connections@v0.6.0golang/github.com/samber/lo@v1.52.0golang/github.com/testcontainers/testcontainers-go@v0.40.0golang/golang.org/x/tools@v0.44.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore golang/golang.org/x/tools@v0.44.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@archandatta archandatta force-pushed the archand/kernel-1213/otel-export branch 2 times, most recently from fa0efbe to 63c9a7d Compare July 7, 2026 14:54
@archandatta archandatta requested review from Sayan- and rgarcia July 7, 2026 19:53
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.
@archandatta archandatta force-pushed the archand/kernel-1213/otel-export branch from 63c9a7d to 5be369e Compare July 8, 2026 12:42

@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.

reviewed — overall looks good. the converter/sink split is clean, mirroring the S2 writer lifecycle was the right call, and the e2e test decoding real OTLP protobuf off the wire is solid. two naming findings:

  • server/cmd/config/config.go:54-56 — env var naming: consider prefixing with something browser-telemetry-specific (e.g. BTEL_*) so these aren't confused with standard OTEL_* env vars that would control kernel-images-api's own telemetry; also RELAY feels overly specified since the endpoint isn't necessarily a relay (a collector in dev)
  • server/cmd/config/config.go:60-62 — nit: field names should be InstanceJWT / InstanceName / MetroName — they're platform identity envs, not OTLP-specific

@Sayan- Sayan- 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.

overall lgtm! left a few comments inline. broader review attached below.

part of me wonders if we should expose an enable/disable for exporting in the api but I don't think we should solve that in this PR

Opus Semantic Review

Contract assumption: forwarding destination coupled with vm lifecycle; exporting best effort.

A. Lifecycle & availability

# Property Why Owner Status Evidence
A1 Init failure must not crash the browser API Optional sink shouldn't take down the VM main.go wiring FAIL main.go:145-148 os.Exit(1) on Start err. Low trigger (lazy otlploghttp.New), but real
A2 Transient runtime export errors don't stop the sink Endpoint flaps must self-heal otlpStorage+StorageWriter PASS Emit async non-blocking; processResult swallows+logs (eventsstorage.go:81); Run returns only on ctx err
A3 Read loop lives whole VM lifecycle [1] always-on requirement StorageWriter.Run PASS Exits only on reader.Read ctx cancel (ringbuffer.go:146)
A4 ~Zero cost when idle Runs on every vanilla VM ringBuffer.Read PASS Parks on <-wake (ringbuffer.go:145-149)

B. Delivery semantics

# Property Why Owner Status Evidence
B1 Loss under pressure is safe (drops, never blocks producers or grows unbounded) A stalled endpoint must not stall capture or OOM the VM ring + batch queue PASS Best-effort by design (matches PR's deferred durability). Enqueue non-blocking, drops oldest (batch.go:305-317); ring evicts oldest. Just declare best-effort as the contract
B2 Flush buffered records on graceful shutdown Don't lose tail on SIGTERM OTLPStorageWriter.Stop PASS Stop→wait doneDrainprovider.Shutdown (main.go:333-339). SIGTERM path only

C. Data correctness

# Property Why Owner Status Evidence
C1 Timestamps wall-clock Suspend-skew correctness converter PASS time.UnixMicro(ev.Ts); Ts wall-clock contract (event.go:79), defaulted in publishLocked
C2 Per-record payload bounded Keep under relay body cap EventStream.Publish PASS truncateIfNeeded ~1MB before ring (eventstream.go:37)

D. Auth & security

# Property Why Owner Status Evidence
D1 Authenticated export Relay must trust the VM main.go headers PASS Bearer KERNEL_INSTANCE_JWT (main.go:131-133)
D2 Secrets not logged Token leak config.LogValue PASS JWT + S2 token redacted (config.go:66-97)

E. Observability & isolation

# Property Why Owner Status Evidence
E1 Export failures observable Silent failure looks like healthy idle loggingExporter + otel global logger FAIL Export errors + ring drops → app slog (no metric). Queue-overflow drops → otel global logger, never wired (otel.SetLogger absent) → silently discarded
E2 OTLP failure isolated from S2 One sink must not degrade the other separate writers + non-blocking emit PASS Independent readers/pipelines; Enqueue non-blocking → stalled endpoint can't backpressure ring or S2

Scorecard

  • PASS (10): A2, A3, A4, B1, B2, C1, C2, D1, D2, E2
  • FAIL (2): A1, E1

Blocking Summary

A1 (don't os.Exit) · E1 (surface failures in logs as aggregate or by window).

Comment thread server/cmd/config/config.go Outdated
S2Stream string `envconfig:"S2_STREAM" default:""`

// OTLP telemetry export. OTLPEndpoint (host[:port]) must be set to enable
// the OTLP sink, which forwards telemetry events to the metro-api relay (or

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.

should drop refs to metro-api

Comment on lines +120 to +125
// otlpSeverity maps an event type to an OTLP severity. It is a deliberately
// coarse, best-effort mapping keyed off the producer's event-type string
// conventions (the literal "console_error" and the "_failed" suffix), not the
// generated event-type constants: those live in the producer package
// (cdpmonitor), which this package cannot import without a cycle. If those type
// names change, update this mapping in lockstep.

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.

this seems fine as a starting point. I think it would be helpful to mux on service crash and oom kill events as well

logger log.Logger
}

func newOTLPStorage(ctx context.Context, cfg OTLPConfig, log *slog.Logger) (*otlpStorage, error) {

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 think this is fine as a starting point. I'd like for us to consider how much of these we want to control externally

Comment thread server/cmd/api/main.go
InstanceName: config.OTLPInstanceName,
Metro: config.OTLPMetro,
}, slogger)
if err := otlpWriter.Start(ctx); err != nil {

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 think this makes sense wrt parity to s2. I'd guess ~some events will race with any intermediate relay having the information required to make routing decisions. may need to reshape depending on what we want to guarantee

archandatta and others added 3 commits July 9, 2026 18:00
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>
@archandatta archandatta marked this pull request as ready for review July 10, 2026 17:53

@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.

Fix All in Cursor

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

Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ff3f848. Configure here.

Comment thread server/lib/events/otlpstorage.go
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>
@pulumi

pulumi Bot commented Jul 13, 2026

Copy link
Copy Markdown

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

@archandatta archandatta requested review from Sayan- and rgarcia July 13, 2026 14:38

@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.

Re-reviewed the follow-up commits: both naming findings addressed (BTEL_OTLP_* prefix, InstanceJWT/InstanceName/MetroName), sink init is now non-fatal, and export requests are byte-bounded with tests. Also verified the queue-drop logging wiring against the pinned SDK — global.Warn maps to logr V(1) → slog level −1, so the LevelInfo - 1 handler admits it. LGTM.

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.

3 participants