Sentry bridge
Apps that use Sentry’s mobile SDKs can send to ouo11y without changing their instrumentation. The collector’s ouosentry receiver accepts Sentry envelopes, forwards each one to your Sentry byte for byte, returns Sentry’s answer unchanged, and then translates a copy into OpenTelemetry: sessions, errors and native crashes, spans, logs, breadcrumbs, mobile vitals, and video replay.
before: https://<public key>@o123.ingest.sentry.io/4504after: https://<public key>@otel.example.com/4504- 1 POST /api/4504/envelope/ · DSN public key
- 2 check the key · unknown key: 403, never forwarded
- 3 the same bytes
- 4 status, headers, body
- 5 unchanged · X-Sentry-Rate-Limits, Retry-After
- 6 translated copy · sessions, errors, spans, replay
What is supported
Section titled “What is supported”@sentry/react-native |
sentry-cocoa | Status |
|---|---|---|
| 8.30.0 to 8.32.0 | 9.30.1, 9.31.0 | supported: sessions, errors and native crashes, http.client spans, backend traces with propagateTraceparent, video replay with touches, logs, breadcrumbs, mobile vitals |
| 7.11.0 (the Expo SDK 57 default) | 8.58.0 | partial, upgrade to 8.x: sessions, errors, crash, replay, logs and navigation work; under Expo 57 the SDK traces no fetch, so no backend correlation and no failed-request records |
| other 8.x | 9.x | expected to work, not captured yet |
Not built yet: native sentry-cocoa apps without React Native, Android SDKs, and browser SDKs (the bridge has no CORS and does not translate rrweb replays).
Translated data carries the resource attribute ouo.rum.mode = sentry, so dashboards can split a rollout by cohort.
Configure it
Section titled “Configure it”The bridge is part of the collector you already run, on port 4320. In the one-file stack it is on, answering locally (upstream: none: nothing leaves your machine). To forward to your Sentry, set in .env:
OUO_SENTRY_UPSTREAM=https://o123.ingest.sentry.ioOUO_SENTRY_PROJECT_ID=4504OUO_SENTRY_PUBLIC_KEY=<the DSN's public key>OUO_SENTRY_SERVICE_NAME=mobile-store-iosThese fill the receiver’s project table. In your own collector config, list one entry per Sentry project:
receivers: ouosentry: endpoint: 0.0.0.0:4320 upstream_timeout: 30s projects: - sentry_project_id: "4504" public_keys: ["<DSN public key>"] upstream: https://o123.ingest.sentry.io # or none ouo_project_id: mobile-store service_name: mobile-store-iosThe VM install routes https://<collector host>/api/* to port 4320, so the DSN needs no port. Then point the app’s DSN at the collector host and ship it.
Exactly one replica
Section titled “Exactly one replica”Sentry SDKs do not put a session id on most items. The bridge learns which session an item belongs to from earlier envelopes of the same device, and keeps that in memory: the session table, replay ids, trace ids, and replay segments and logs waiting for their session (up to 10 minutes and 1 minute). It is bounded (state.max_sessions, default 200,000 sessions, 4 hours after the last update) and never written to disk.
Forwarding to Sentry is stateless: Sentry receives every envelope unchanged with any number of replicas. What breaks with two is the ouo11y copy, because a load balancer splits one device’s envelopes between them:
| In ouo11y | With two replicas behind a round-robin balancer |
|---|---|
| spans, logs and errors sent without a session item in the same envelope | matched only on the replica that saw the session; on the other they have no session.id and are missing from the timeline |
| replay | segments on the replica that does not know the replay are held 10 minutes, then dropped: gaps |
| error counts | too many errors on errored sessions |
session.previous_id, startup logs |
lost when the other replica saw the previous session |
Sticky routing by client address does not save it: phones move between Wi-Fi and cellular. Run one.
Restarts lose the state too. Sessions already running are picked up mid-way, held replay segments are lost, and a duplicate session.start is possible (harmless). Restart in quiet hours.
Sizing. Translation runs off the request path, from a bounded queue (translate_queue_size, 10,000 envelopes). Give the pod more CPU before thinking of more replicas. Watch:
sum(rate(ouo_sentry_translate_dropped_total{reason="queue_full"}[5m]))Those envelopes reached Sentry but were not translated.
On Docker Compose and the VM
Section titled “On Docker Compose and the VM”Nothing to do: one collector container runs the bridge. Never docker compose up --scale otel-collector=2.
On Kubernetes
Section titled “On Kubernetes”The Helm chart does not deploy the bridge. Run it as its own Deployment with one replica next to the chart, so the main collector can still scale. These manifests assume the release ouo in namespace ouo, bundled LGTM, and the rum-api Secret ouo-rum-api-tokens from Helm; check the names with kubectl get svc,secret -n ouo.
-
Save as
sentry-bridge.yaml, with your Sentry project id, DSN key, upstream and ouo11y project:apiVersion: v1kind: ConfigMapmetadata:name: ouo-sentry-bridgenamespace: ouodata:config.yaml: |extensions:health_check:endpoint: 0.0.0.0:13133receivers:ouosentry:endpoint: 0.0.0.0:4320upstream_timeout: 30sprojects:- sentry_project_id: "4504"public_keys: ["<DSN public key>"]upstream: https://o123.ingest.sentry.ioouo_project_id: mobile-storeservice_name: mobile-store-iosconnectors:routing/sentry:default_pipelines: [logs]table:- context: logcondition: log.attributes["ouo.replay.chunk"] == truepipelines: [logs/replay]processors:memory_limiter: { check_interval: 1s, limit_mib: 512, spike_limit_mib: 128 }ouoratelimit:project: { rate: 1000, burst: 5000 }client: { rate: 100, burst: 300, key_from: address }resource/project:attributes:- { key: ouo.project.id, action: delete }- { key: ouo.project.id, from_context: auth.username, action: upsert }transform/events:error_mode: ignorelog_statements:- context: logstatements:- set(log.attributes["event.name"], log.event_name) where log.event_name != "" and log.attributes["event.name"] == nil- set(log.attributes["ouo.platform"], resource.attributes["ouo.platform"]) where log.attributes["ouo.platform"] == nil and resource.attributes["ouo.platform"] != nilbatch: { timeout: 1s, send_batch_size: 512, send_batch_max_size: 1024 }exporters:otlp_grpc/tempo: { endpoint: "ouo-tempo:4317", tls: { insecure: true } }otlp_http/loki: { endpoint: "http://ouo-loki:3100/otlp", tls: { insecure: true } }ouoreplay:endpoint: http://ouo-rum-api:8080ingest_token: ${env:OUO_INGEST_TOKEN}sending_queue: { enabled: true, queue_size: 1000 }retry_on_failure: { enabled: true, max_elapsed_time: 5m }service:extensions: [health_check]telemetry:metrics:level: normalreaders:- periodic:interval: 15000exporter:otlp: { protocol: http/protobuf, endpoint: "http://ouo-mimir:9009/otlp/v1/metrics" }pipelines:traces:receivers: [ouosentry]processors: [memory_limiter, ouoratelimit, resource/project, batch]exporters: [otlp_grpc/tempo]logs/sentry:receivers: [ouosentry]processors: [memory_limiter]exporters: [routing/sentry]logs:receivers: [routing/sentry]processors: [memory_limiter, ouoratelimit, resource/project, transform/events, batch]exporters: [otlp_http/loki]logs/replay:receivers: [routing/sentry]processors: [memory_limiter, ouoratelimit]exporters: [ouoreplay]---apiVersion: apps/v1kind: Deploymentmetadata:name: ouo-sentry-bridgenamespace: ouospec:replicas: 1 # never more: the session state is in memorystrategy:type: Recreate # no overlap of the old and the new podselector:matchLabels: { app.kubernetes.io/name: ouo-sentry-bridge }template:metadata:labels: { app.kubernetes.io/name: ouo-sentry-bridge }spec:imagePullSecrets: [{ name: ghcr }]containers:- name: bridgeimage: ghcr.io/ouo11y/collector:0.4.0 # the same version as the chart's collectorargs: ["--config=/etc/otelcol/config.yaml"]env:- name: OUO_INGEST_TOKENvalueFrom: { secretKeyRef: { name: ouo-rum-api-tokens, key: ingest-token } }ports:- { name: sentry, containerPort: 4320 }readinessProbe: { httpGet: { path: /, port: 13133 } }livenessProbe: { httpGet: { path: /, port: 13133 } }resources:requests: { cpu: 250m, memory: 256Mi }limits: { memory: 768Mi }volumeMounts:- { name: config, mountPath: /etc/otelcol }volumes:- name: configconfigMap: { name: ouo-sentry-bridge }---apiVersion: v1kind: Servicemetadata:name: ouo-sentry-bridgenamespace: ouospec:selector: { app.kubernetes.io/name: ouo-sentry-bridge }ports:- { name: sentry, port: 4320, targetPort: sentry }It exports straight to the chart’s Tempo, Loki and rum-api, because the main collector requires a project credential that the bridge does not have.
-
Apply it:
Terminal window kubectl apply -f sentry-bridge.yaml -
Expose the Service on its own host (
sentry-bridge.example.com) with your Ingress controller and TLS. The DSN host becomes that name, with no port. -
Check it:
kubectl -n ouo get deploy ouo-sentry-bridgeshows1/1. After the app sent a session, this grows in Mimir, and the session appears in the ouo11y RUM app:sum by (outcome) (rate(ouo_sentry_envelopes_total[5m]))
No HorizontalPodAutoscaler, no kubectl scale. Upgrade the bridge’s image together with the chart’s collector.