Skip to content

Commit 7fcfdf6

Browse files
authored
fix(telemetry): send browser events to the configured collector and keep logs off the anonymous one (#9317)
* fix(telemetry): send browser events to the configured collector and keep logs off the anonymous one The browser telemetry route read only TELEMETRY_ENDPOINT, so a deployment exporting to its own collector through OTEL_EXPORTER_OTLP_ENDPOINT still posted browser events to the anonymous default, ignored NEXT_TELEMETRY_DISABLED, and built spans without trace or span ids under the retired instrumentationLibrarySpans field. It now resolves the endpoint and headers exactly like the server export, honors the disable flag, and emits valid OTLP. Server logs and metrics are no longer exported to the anonymous collector, which receives traces only. * fix(telemetry): never send collector credentials to the anonymous endpoint, match it by host OTEL_EXPORTER_OTLP_HEADERS belong to the operator's collector, so they are dropped when the endpoint resolves to the anonymous one; that check now compares hosts, so any spelling of the anonymous URL gets traces only. The browser relay falls back to telemetry.config.ts like the server export, and the traces URL is built on the parsed path, keeping query strings and accepting a trailing slash. * fix(telemetry): never attach operator collector credentials to browser events /api/telemetry relays unauthenticated input, so sending it with OTEL_EXPORTER_OTLP_HEADERS let anyone write spans into the operator's authenticated collector. Browser events now go only to TELEMETRY_ENDPOINT or the configured anonymous collector, without credentials; the server's own export keeps using the operator's collector. * fix(telemetry): post browser events to TELEMETRY_ENDPOINT as-is and document routing per side
1 parent 0d3dda9 commit 7fcfdf6

5 files changed

Lines changed: 113 additions & 54 deletions

File tree

‎apps/docs/content/docs/platform/self-hosting/environment-variables.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ helm template sim oci://ghcr.io/simstudioai/charts/sim --version 1.11.8 -f value
298298
| `OTEL_TRACES_SAMPLER_ARG` | Trace sampling ratio |
299299
| `OTEL_DEPLOYMENT_ENVIRONMENT` | Environment label on emitted spans |
300300
| `TELEMETRY_SAMPLING_RATIO` | Application-level sampling ratio |
301-
| `TELEMETRY_ENDPOINT` | Where anonymous telemetry is sent. Defaults to `https://telemetry.simstudio.ai/v1/traces` — point it at your own collector to keep traces internal |
301+
| `TELEMETRY_ENDPOINT` | Full URL browser telemetry events are posted to, as-is (they never carry `OTEL_EXPORTER_OTLP_HEADERS`). Server traces use it only when `OTEL_EXPORTER_OTLP_ENDPOINT` is unset. Defaults to `https://telemetry.simstudio.ai/v1/traces`, which receives traces only: server logs and metrics are exported only to a collector you configure. `NEXT_TELEMETRY_DISABLED=1` (the Docker and Helm default) turns all of it off |
302302
| `NEXT_TELEMETRY_DISABLED` | Set to `1` to disable anonymous telemetry entirely |
303303
| `GRAFANA_OTLP_ENDPOINT` / `GRAFANA_OTLP_HEADERS` / `GRAFANA_DEPLOYMENT_ENVIRONMENT` | Grafana Cloud OTLP export |
304304

‎apps/sim/app/api/telemetry/route.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
3+
import { generateRandomHex } from '@sim/utils/random'
34
import { type NextRequest, NextResponse } from 'next/server'
45
import { telemetryContract } from '@/lib/api/contracts/telemetry'
56
import { parseRequest } from '@/lib/api/server'
6-
import { env } from '@/lib/core/config/env'
77
import { isProd } from '@/lib/core/config/env-flags'
88
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
99
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
import { isTelemetryDisabled, normalizeOtlpTracesUrl } from '@/lib/monitoring/otlp'
11+
import telemetryConfig from '@/telemetry.config'
1012

1113
const logger = createLogger('TelemetryAPI')
1214

@@ -52,15 +54,20 @@ function createSafeAttributes(
5254
}
5355

5456
/**
55-
* Forwards telemetry data to OpenTelemetry collector
57+
* Forwards a browser telemetry event as one span, and not at all when the operator turned telemetry
58+
* off. Browser events are unauthenticated input, so they never travel with the operator's
59+
* collector credentials: they go to `TELEMETRY_ENDPOINT` or the configured anonymous collector.
5660
*/
5761
async function forwardToCollector(data: Record<string, unknown>): Promise<boolean> {
5862
if (!data || typeof data !== 'object') {
5963
logger.error('Invalid telemetry data format')
6064
return false
6165
}
66+
if (isTelemetryDisabled()) return false
6267

63-
const endpoint = env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces'
68+
// TELEMETRY_ENDPOINT has always been the relay's full destination URL, so it is posted to as-is.
69+
const endpoint =
70+
process.env.TELEMETRY_ENDPOINT || normalizeOtlpTracesUrl(telemetryConfig.endpoint)
6471
const timeout = DEFAULT_TIMEOUT
6572

6673
try {
@@ -89,10 +96,12 @@ async function forwardToCollector(data: Record<string, unknown>): Promise<boolea
8996
resource: {
9097
attributes: serviceAttrs,
9198
},
92-
instrumentationLibrarySpans: [
99+
scopeSpans: [
93100
{
94101
spans: [
95102
{
103+
traceId: generateRandomHex(32),
104+
spanId: generateRandomHex(16),
96105
name: spanName,
97106
kind: 1,
98107
startTimeUnixNano: timestamp,

‎apps/sim/instrumentation-node.ts‎

Lines changed: 36 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ import type {
1515
import { createLogger } from '@sim/logger'
1616
import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1'
1717
import { env } from './lib/core/config/env'
18-
import { parseOtlpHeaders } from './lib/monitoring/otlp'
18+
import {
19+
ANONYMOUS_TELEMETRY_ENDPOINT,
20+
isTelemetryDisabled,
21+
normalizeOtlpTracesUrl,
22+
resolveOtlpTarget,
23+
} from './lib/monitoring/otlp'
1924

2025
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.ERROR)
2126

@@ -27,7 +32,7 @@ const SPAN_NAME_PREFIX = `${MOTHERSHIP_ORIGIN}: `
2732
const SERVICE_INSTANCE_SLUG = 'sim' as const
2833

2934
const DEFAULT_TELEMETRY_CONFIG = {
30-
endpoint: env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces',
35+
endpoint: ANONYMOUS_TELEMETRY_ENDPOINT,
3136
serviceName: 'mothership',
3237
serviceVersion: '0.1.0',
3338
serverSide: { enabled: true },
@@ -49,21 +54,6 @@ function isBusinessSpan(spanName: string): boolean {
4954
return ALLOWED_SPAN_PREFIXES.some((prefix) => spanName.startsWith(prefix))
5055
}
5156

52-
// Append `/v1/traces` to the OTLP base URL unless already present.
53-
// The HTTP exporter doesn't auto-suffix the signal path even though
54-
// the spec says the env var is a base URL.
55-
function normalizeOtlpTracesUrl(url: string): string {
56-
if (!url) return url
57-
try {
58-
const u = new URL(url)
59-
if (u.pathname.endsWith('/v1/traces')) return url
60-
const base = url.replace(/\/$/, '')
61-
return `${base}/v1/traces`
62-
} catch {
63-
return url
64-
}
65-
}
66-
6757
// Metrics counterpart to `normalizeOtlpTracesUrl`. Operates on the parsed
6858
// pathname (not a raw string suffix) so query strings and trailing slashes
6959
// don't corrupt the result: swap a `/v1/traces` suffix for `/v1/metrics`,
@@ -151,7 +141,7 @@ class MothershipOriginSpanProcessor implements SpanProcessor {
151141

152142
async function initializeOpenTelemetry() {
153143
try {
154-
if (env.NEXT_TELEMETRY_DISABLED === '1' || process.env.NEXT_TELEMETRY_DISABLED === '1') {
144+
if (isTelemetryDisabled()) {
155145
logger.info('OpenTelemetry disabled via NEXT_TELEMETRY_DISABLED=1')
156146
return
157147
}
@@ -163,15 +153,10 @@ async function initializeOpenTelemetry() {
163153
telemetryConfig = DEFAULT_TELEMETRY_CONFIG
164154
}
165155

166-
// Prefer the OTel spec env var, fall back to legacy TELEMETRY_ENDPOINT.
167-
const resolvedEndpoint =
168-
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ||
169-
process.env.TELEMETRY_ENDPOINT ||
170-
env.TELEMETRY_ENDPOINT ||
171-
telemetryConfig.endpoint
156+
const target = resolveOtlpTarget(telemetryConfig.endpoint)
172157
telemetryConfig = {
173158
...telemetryConfig,
174-
endpoint: resolvedEndpoint,
159+
endpoint: target.endpoint,
175160
serviceName: 'mothership',
176161
}
177162

@@ -244,7 +229,7 @@ async function initializeOpenTelemetry() {
244229
},
245230
})
246231

247-
const otlpHeaders = parseOtlpHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS || '')
232+
const otlpHeaders = target.headers
248233
const exporterUrl = normalizeOtlpTracesUrl(telemetryConfig.endpoint)
249234

250235
const exporter = new OTLPTraceExporter({
@@ -279,28 +264,33 @@ async function initializeOpenTelemetry() {
279264

280265
// Metrics (hosted-key counters/histograms) share the trace endpoint and
281266
// headers — only the signal path differs. Unlike spans these aren't sampled.
282-
const metricReader = new PeriodicExportingMetricReader({
283-
exporter: new OTLPMetricExporter({
284-
url: normalizeOtlpMetricsUrl(telemetryConfig.endpoint),
285-
headers: otlpHeaders,
286-
timeoutMillis: Math.min(telemetryConfig.batchSettings.exportTimeoutMillis, 10000),
287-
keepAlive: false,
288-
}),
289-
exportIntervalMillis: 60000,
290-
})
267+
// Neither metrics nor logs go to the anonymous collector, which takes traces only.
268+
const metricReader = target.anonymous
269+
? undefined
270+
: new PeriodicExportingMetricReader({
271+
exporter: new OTLPMetricExporter({
272+
url: normalizeOtlpMetricsUrl(telemetryConfig.endpoint),
273+
headers: otlpHeaders,
274+
timeoutMillis: Math.min(telemetryConfig.batchSettings.exportTimeoutMillis, 10000),
275+
keepAlive: false,
276+
}),
277+
exportIntervalMillis: 60000,
278+
})
291279

292280
// Logs share the trace endpoint and headers as well (signal path
293281
// /v1/logs). Every @sim/logger line fans out through the global Logs API
294282
// (see packages/logger), which the NodeSDK wires to this processor — the
295283
// stdout JSON lines continue to CloudWatch unchanged.
296-
const logRecordProcessor = new BatchLogRecordProcessor({
297-
exporter: new OTLPLogExporter({
298-
url: normalizeOtlpLogsUrl(telemetryConfig.endpoint),
299-
headers: otlpHeaders,
300-
timeoutMillis: Math.min(telemetryConfig.batchSettings.exportTimeoutMillis, 10000),
301-
keepAlive: false,
302-
}),
303-
})
284+
const logRecordProcessor = target.anonymous
285+
? undefined
286+
: new BatchLogRecordProcessor({
287+
exporter: new OTLPLogExporter({
288+
url: normalizeOtlpLogsUrl(telemetryConfig.endpoint),
289+
headers: otlpHeaders,
290+
timeoutMillis: Math.min(telemetryConfig.batchSettings.exportTimeoutMillis, 10000),
291+
keepAlive: false,
292+
}),
293+
})
304294

305295
// Must be unique per process: replicas sharing one instance id collapse
306296
// into a single Prometheus series, so their independent cumulative
@@ -350,8 +340,9 @@ async function initializeOpenTelemetry() {
350340
resource,
351341
spanProcessors,
352342
sampler,
353-
metricReader,
354-
logRecordProcessors: [logRecordProcessor],
343+
// Explicit empty lists: left undefined, NodeSDK builds default OTLP exporters from env.
344+
metricReaders: metricReader ? [metricReader] : [],
345+
logRecordProcessors: logRecordProcessor ? [logRecordProcessor] : [],
355346
})
356347

357348
sdk.start()

‎apps/sim/lib/monitoring/otlp.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,62 @@ export function parseOtlpHeaders(raw: string): Record<string, string> {
2525
}
2626
return out
2727
}
28+
29+
/**
30+
* Sim's anonymous telemetry collector, used when no collector is configured. It receives traces
31+
* only: logs and metrics carry an instance's own operational detail and go only to a collector
32+
* its operator chose.
33+
*/
34+
export const ANONYMOUS_TELEMETRY_ENDPOINT = 'https://telemetry.simstudio.ai/v1/traces'
35+
36+
/** True when the operator turned telemetry off (`NEXT_TELEMETRY_DISABLED=1`). */
37+
export function isTelemetryDisabled(): boolean {
38+
return process.env.NEXT_TELEMETRY_DISABLED === '1'
39+
}
40+
41+
/** True when `endpoint` names the anonymous collector's host, however the URL is spelled. */
42+
function isAnonymousCollector(endpoint: string): boolean {
43+
try {
44+
return new URL(endpoint).host === new URL(ANONYMOUS_TELEMETRY_ENDPOINT).host
45+
} catch {
46+
return false
47+
}
48+
}
49+
50+
/**
51+
* The collector this process sends telemetry to: `OTEL_EXPORTER_OTLP_ENDPOINT`, then the legacy
52+
* `TELEMETRY_ENDPOINT`, then `fallback` (a fork's `telemetry.config.ts` endpoint, or the anonymous
53+
* collector). `OTEL_EXPORTER_OTLP_HEADERS` belong to the operator's own collector, so they are
54+
* never sent to the anonymous one.
55+
*/
56+
export function resolveOtlpTarget(fallback: string = ANONYMOUS_TELEMETRY_ENDPOINT): {
57+
endpoint: string
58+
headers: Record<string, string>
59+
anonymous: boolean
60+
} {
61+
const endpoint =
62+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || process.env.TELEMETRY_ENDPOINT || fallback
63+
const anonymous = isAnonymousCollector(endpoint)
64+
return {
65+
endpoint,
66+
headers: anonymous ? {} : parseOtlpHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS || ''),
67+
anonymous,
68+
}
69+
}
70+
71+
/**
72+
* Appends `/v1/traces` to an OTLP base URL's path unless it already ends there (with or without a
73+
* trailing slash), keeping any query string. The HTTP exporter doesn't auto-suffix the signal path
74+
* even though the spec says the env var is a base URL.
75+
*/
76+
export function normalizeOtlpTracesUrl(url: string): string {
77+
if (!url) return url
78+
try {
79+
const u = new URL(url)
80+
const path = u.pathname.replace(/\/+$/, '')
81+
u.pathname = path.endsWith('/v1/traces') ? path : `${path}/v1/traces`
82+
return u.toString()
83+
} catch {
84+
return url
85+
}
86+
}

‎apps/sim/telemetry.config.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,15 @@
2323
* - API keys or tokens
2424
* - IP addresses or geolocation data
2525
*/
26-
import { env } from './lib/core/config/env'
26+
import { ANONYMOUS_TELEMETRY_ENDPOINT } from './lib/monitoring/otlp'
2727

2828
const config = {
2929
/**
30-
* OTLP Endpoint URL where telemetry data is sent
31-
* Change this if you want to send telemetry to your own collector
30+
* OTLP Endpoint URL where telemetry data is sent when neither OTEL_EXPORTER_OTLP_ENDPOINT nor
31+
* TELEMETRY_ENDPOINT is set. Change this if you want to send telemetry to your own collector.
3232
* Supports any OTLP-compatible backend (Jaeger, Grafana Tempo, etc.)
3333
*/
34-
endpoint: env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces',
34+
endpoint: ANONYMOUS_TELEMETRY_ENDPOINT,
3535

3636
/**
3737
* Service name used to identify this instance

0 commit comments

Comments
 (0)