d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
368 lines
13 KiB
TypeScript
368 lines
13 KiB
TypeScript
// Sim OTel bootstrap. Filter by `mothership.origin` or span-name
|
|
// prefix (`sim-mothership:` / `go-mothership:`) to separate the two
|
|
// halves of a mothership trace in the OTLP backend.
|
|
|
|
import { hostname } from 'node:os'
|
|
import type { Attributes, Context, Link, SpanKind } from '@opentelemetry/api'
|
|
import { DiagConsoleLogger, DiagLogLevel, diag, TraceFlags, trace } from '@opentelemetry/api'
|
|
import type {
|
|
ReadableSpan,
|
|
Sampler,
|
|
SamplingResult,
|
|
Span,
|
|
SpanProcessor,
|
|
} from '@opentelemetry/sdk-trace-base'
|
|
import { createLogger } from '@sim/logger'
|
|
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
|
|
import { env } from './lib/core/config/env'
|
|
import { parseOtlpHeaders } from './lib/monitoring/otlp'
|
|
|
|
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.ERROR)
|
|
|
|
const logger = createLogger('OTelInstrumentation')
|
|
|
|
const MOTHERSHIP_ORIGIN = 'sim-mothership' as const
|
|
const SPAN_NAME_PREFIX = `${MOTHERSHIP_ORIGIN}: `
|
|
|
|
const SERVICE_INSTANCE_SLUG = 'sim' as const
|
|
|
|
const DEFAULT_TELEMETRY_CONFIG = {
|
|
endpoint: env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces',
|
|
serviceName: 'mothership',
|
|
serviceVersion: '0.1.0',
|
|
serverSide: { enabled: true },
|
|
batchSettings: {
|
|
maxQueueSize: 2048,
|
|
maxExportBatchSize: 512,
|
|
scheduledDelayMillis: 5000,
|
|
exportTimeoutMillis: 30000,
|
|
},
|
|
}
|
|
|
|
// Allowlist of span-name prefixes exported from this process.
|
|
// Non-mothership code (workflow executor, block runtime, framework
|
|
// noise) is dropped. Broaden carefully — `http.` etc. would reopen
|
|
// the firehose.
|
|
const ALLOWED_SPAN_PREFIXES = ['gen_ai.', 'copilot.', 'sim →', 'sim.', 'tool.execute']
|
|
|
|
function isBusinessSpan(spanName: string): boolean {
|
|
return ALLOWED_SPAN_PREFIXES.some((prefix) => spanName.startsWith(prefix))
|
|
}
|
|
|
|
// Append `/v1/traces` to the OTLP base URL unless already present.
|
|
// The HTTP exporter doesn't auto-suffix the signal path even though
|
|
// the spec says the env var is a base URL.
|
|
function normalizeOtlpTracesUrl(url: string): string {
|
|
if (!url) return url
|
|
try {
|
|
const u = new URL(url)
|
|
if (u.pathname.endsWith('/v1/traces')) return url
|
|
const base = url.replace(/\/$/, '')
|
|
return `${base}/v1/traces`
|
|
} catch {
|
|
return url
|
|
}
|
|
}
|
|
|
|
// Metrics counterpart to `normalizeOtlpTracesUrl`. Operates on the parsed
|
|
// pathname (not a raw string suffix) so query strings and trailing slashes
|
|
// don't corrupt the result: swap a `/v1/traces` suffix for `/v1/metrics`,
|
|
// otherwise append `/v1/metrics`.
|
|
function normalizeOtlpMetricsUrl(url: string): string {
|
|
if (!url) return url
|
|
try {
|
|
const u = new URL(url)
|
|
const path = u.pathname.replace(/\/$/, '')
|
|
if (path.endsWith('/v1/metrics')) return url
|
|
u.pathname = path.endsWith('/v1/traces')
|
|
? path.replace(/\/v1\/traces$/, '/v1/metrics')
|
|
: `${path}/v1/metrics`
|
|
return u.toString()
|
|
} catch {
|
|
return url
|
|
}
|
|
}
|
|
|
|
// deployment.environment in the GO value space (dev | staging | prod) without
|
|
// any new infra env var. Every deployed Sim tier already gets
|
|
// APPCONFIG_ENVIRONMENT = the infra env name (dev | staging | production), so we
|
|
// reuse it and map production -> prod to match Go's `OTEL_DEPLOYMENT_ENVIRONMENT`
|
|
// (and thus a single Grafana $env filter spans Sim + Go). Returns undefined when
|
|
// unset (local dev) so the OTEL_/NODE_ENV fallbacks still apply.
|
|
function deploymentEnvFromAppConfig(v: string | undefined): string | undefined {
|
|
if (!v) return undefined
|
|
return v === 'production' ? 'prod' : v
|
|
}
|
|
|
|
// Sampling ratio from env (mirrors Go's `samplerFromEnv`); fallback
|
|
// is 100% everywhere. Retention caps cost, not sampling.
|
|
function resolveSamplingRatio(_isLocalEndpoint: boolean): number {
|
|
const raw = process.env.TELEMETRY_SAMPLING_RATIO || process.env.OTEL_TRACES_SAMPLER_ARG || ''
|
|
if (raw) {
|
|
const parsed = Number.parseFloat(raw)
|
|
if (Number.isFinite(parsed)) {
|
|
if (parsed <= 0) return 0
|
|
if (parsed >= 1) return 1
|
|
return parsed
|
|
}
|
|
}
|
|
return 1.0
|
|
}
|
|
|
|
// Tags allowed spans with `mothership.origin` and prepends
|
|
// `sim-mothership:` to the span name so backends can visually split
|
|
// the two halves even when service.name is shared.
|
|
class MothershipOriginSpanProcessor implements SpanProcessor {
|
|
onStart(span: Span): void {
|
|
const name = span.name
|
|
if (!isBusinessSpan(name)) {
|
|
return
|
|
}
|
|
span.setAttribute(TraceAttr.MothershipOrigin, MOTHERSHIP_ORIGIN)
|
|
if (!name.startsWith(SPAN_NAME_PREFIX)) {
|
|
span.updateName(`${SPAN_NAME_PREFIX}${name}`)
|
|
}
|
|
}
|
|
onEnd(_span: ReadableSpan): void {}
|
|
shutdown(): Promise<void> {
|
|
return Promise.resolve()
|
|
}
|
|
forceFlush(): Promise<void> {
|
|
return Promise.resolve()
|
|
}
|
|
}
|
|
|
|
async function initializeOpenTelemetry() {
|
|
try {
|
|
if (env.NEXT_TELEMETRY_DISABLED === '1' || process.env.NEXT_TELEMETRY_DISABLED === '1') {
|
|
logger.info('OpenTelemetry disabled via NEXT_TELEMETRY_DISABLED=1')
|
|
return
|
|
}
|
|
|
|
let telemetryConfig
|
|
try {
|
|
telemetryConfig = (await import('./telemetry.config')).default
|
|
} catch {
|
|
telemetryConfig = DEFAULT_TELEMETRY_CONFIG
|
|
}
|
|
|
|
// Prefer the OTel spec env var, fall back to legacy TELEMETRY_ENDPOINT.
|
|
const resolvedEndpoint =
|
|
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ||
|
|
process.env.TELEMETRY_ENDPOINT ||
|
|
env.TELEMETRY_ENDPOINT ||
|
|
telemetryConfig.endpoint
|
|
telemetryConfig = {
|
|
...telemetryConfig,
|
|
endpoint: resolvedEndpoint,
|
|
serviceName: 'mothership',
|
|
}
|
|
|
|
if (telemetryConfig.serverSide?.enabled === false) {
|
|
logger.info('Server-side OpenTelemetry disabled in config')
|
|
return
|
|
}
|
|
|
|
logger.info('OpenTelemetry init', {
|
|
endpoint: telemetryConfig.endpoint,
|
|
serviceName: telemetryConfig.serviceName,
|
|
origin: MOTHERSHIP_ORIGIN,
|
|
})
|
|
|
|
const { NodeSDK } = await import('@opentelemetry/sdk-node')
|
|
const { defaultResource, resourceFromAttributes } = await import('@opentelemetry/resources')
|
|
const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, ATTR_DEPLOYMENT_ENVIRONMENT } = await import(
|
|
'@opentelemetry/semantic-conventions/incubating'
|
|
)
|
|
const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-http')
|
|
const { OTLPMetricExporter } = await import('@opentelemetry/exporter-metrics-otlp-http')
|
|
const { PeriodicExportingMetricReader } = await import('@opentelemetry/sdk-metrics')
|
|
const { BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-node')
|
|
const { TraceIdRatioBasedSampler, SamplingDecision } = await import(
|
|
'@opentelemetry/sdk-trace-base'
|
|
)
|
|
|
|
// Drops Next framework spans, inherits SAMPLED from business
|
|
// parents, and re-samples business roots fresh (don't delegate to
|
|
// ParentBased — its unsampled-parent path is AlwaysOff by default).
|
|
const createBusinessSpanSampler = (rootRatioSampler: Sampler): Sampler => ({
|
|
shouldSample(
|
|
context: Context,
|
|
traceId: string,
|
|
spanName: string,
|
|
spanKind: SpanKind,
|
|
attributes: Attributes,
|
|
links: Link[]
|
|
): SamplingResult {
|
|
if (attributes['next.span_type']) {
|
|
return { decision: SamplingDecision.NOT_RECORD }
|
|
}
|
|
|
|
const parentSpanContext = trace.getSpanContext(context)
|
|
const parentIsSampled =
|
|
!!parentSpanContext &&
|
|
(parentSpanContext.traceFlags & TraceFlags.SAMPLED) === TraceFlags.SAMPLED
|
|
|
|
if (parentIsSampled) {
|
|
return { decision: SamplingDecision.RECORD_AND_SAMPLED }
|
|
}
|
|
|
|
if (isBusinessSpan(spanName)) {
|
|
return rootRatioSampler.shouldSample(
|
|
context,
|
|
traceId,
|
|
spanName,
|
|
spanKind,
|
|
attributes,
|
|
links
|
|
)
|
|
}
|
|
|
|
return { decision: SamplingDecision.NOT_RECORD }
|
|
},
|
|
toString(): string {
|
|
return `BusinessSpanSampler{rootSampler=${rootRatioSampler.toString()}}`
|
|
},
|
|
})
|
|
|
|
const otlpHeaders = parseOtlpHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS || '')
|
|
const exporterUrl = normalizeOtlpTracesUrl(telemetryConfig.endpoint)
|
|
|
|
const exporter = new OTLPTraceExporter({
|
|
url: exporterUrl,
|
|
headers: otlpHeaders,
|
|
timeoutMillis: Math.min(telemetryConfig.batchSettings.exportTimeoutMillis, 10000),
|
|
keepAlive: false,
|
|
})
|
|
|
|
// Surface export failures (BatchSpanProcessor swallows them otherwise).
|
|
const origExport = exporter.export.bind(exporter)
|
|
exporter.export = (spans, resultCallback) => {
|
|
origExport(spans, (result) => {
|
|
if (result?.code !== 0) {
|
|
// eslint-disable-next-line no-console
|
|
console.error('[OTEL] exporter export failed', {
|
|
endpoint: telemetryConfig.endpoint,
|
|
resultCode: result?.code,
|
|
error: result?.error?.message,
|
|
spanCount: spans.length,
|
|
})
|
|
}
|
|
resultCallback(result)
|
|
})
|
|
}
|
|
|
|
const batchProcessor = new BatchSpanProcessor(exporter, {
|
|
maxQueueSize: telemetryConfig.batchSettings.maxQueueSize,
|
|
maxExportBatchSize: telemetryConfig.batchSettings.maxExportBatchSize,
|
|
scheduledDelayMillis: telemetryConfig.batchSettings.scheduledDelayMillis,
|
|
exportTimeoutMillis: telemetryConfig.batchSettings.exportTimeoutMillis,
|
|
})
|
|
|
|
// Metrics (hosted-key counters/histograms) share the trace endpoint and
|
|
// headers — only the signal path differs. Unlike spans these aren't sampled.
|
|
const metricReader = new PeriodicExportingMetricReader({
|
|
exporter: new OTLPMetricExporter({
|
|
url: normalizeOtlpMetricsUrl(telemetryConfig.endpoint),
|
|
headers: otlpHeaders,
|
|
timeoutMillis: Math.min(telemetryConfig.batchSettings.exportTimeoutMillis, 10000),
|
|
keepAlive: false,
|
|
}),
|
|
exportIntervalMillis: 60000,
|
|
})
|
|
|
|
// Must be unique per process: replicas sharing one instance id collapse
|
|
// into a single Prometheus series, so their independent cumulative
|
|
// counters interleave and corrupt rate()/increase(). The slug keeps Sim
|
|
// distinct from Go for Jaeger's clock-skew grouping; the hostname (the
|
|
// container id under ECS) makes each replica its own series.
|
|
const serviceInstanceId = `${telemetryConfig.serviceName}-${SERVICE_INSTANCE_SLUG}-${hostname()}`
|
|
const resource = defaultResource().merge(
|
|
resourceFromAttributes({
|
|
[ATTR_SERVICE_NAME]: telemetryConfig.serviceName,
|
|
[ATTR_SERVICE_VERSION]: telemetryConfig.serviceVersion,
|
|
// OTEL_ → DEPLOYMENT_ENVIRONMENT → APPCONFIG_ENVIRONMENT (mapped to the
|
|
// Go value space) → NODE_ENV. Matches Go's `resourceEnvFromEnv()` so a
|
|
// single $env spans Sim + Go. APPCONFIG_ENVIRONMENT (already set on every
|
|
// deployed tier) is the fix that stops deployed Sim tagging everything
|
|
// "production" via the NODE_ENV fallback — no new infra env var needed.
|
|
[ATTR_DEPLOYMENT_ENVIRONMENT]:
|
|
process.env.OTEL_DEPLOYMENT_ENVIRONMENT ||
|
|
process.env.DEPLOYMENT_ENVIRONMENT ||
|
|
deploymentEnvFromAppConfig(process.env.APPCONFIG_ENVIRONMENT) ||
|
|
env.NODE_ENV ||
|
|
'development',
|
|
'service.namespace': 'mothership',
|
|
'service.instance.id': serviceInstanceId,
|
|
'mothership.origin': MOTHERSHIP_ORIGIN,
|
|
'telemetry.sdk.name': 'opentelemetry',
|
|
'telemetry.sdk.language': 'nodejs',
|
|
'telemetry.sdk.version': '1.0.0',
|
|
})
|
|
)
|
|
|
|
const isLocalEndpoint = /localhost|127\.0\.0\.1/i.test(telemetryConfig.endpoint)
|
|
const samplingRatio = resolveSamplingRatio(isLocalEndpoint)
|
|
const rootRatioSampler = new TraceIdRatioBasedSampler(samplingRatio)
|
|
const sampler = createBusinessSpanSampler(rootRatioSampler)
|
|
|
|
logger.info('OpenTelemetry sampler configured', {
|
|
samplingRatio,
|
|
endpoint: telemetryConfig.endpoint,
|
|
origin: MOTHERSHIP_ORIGIN,
|
|
})
|
|
|
|
// Origin-prefix must run before batch so the rename/attr is captured.
|
|
const spanProcessors: SpanProcessor[] = [new MothershipOriginSpanProcessor(), batchProcessor]
|
|
|
|
const sdk = new NodeSDK({
|
|
resource,
|
|
spanProcessors,
|
|
sampler,
|
|
metricReader,
|
|
})
|
|
|
|
sdk.start()
|
|
|
|
const shutdownOtel = async () => {
|
|
try {
|
|
await sdk.shutdown()
|
|
logger.info('OpenTelemetry SDK shut down successfully')
|
|
} catch (err) {
|
|
logger.error('Error shutting down OpenTelemetry SDK', err)
|
|
}
|
|
}
|
|
|
|
process.on('SIGTERM', shutdownOtel)
|
|
process.on('SIGINT', shutdownOtel)
|
|
|
|
logger.info('OpenTelemetry instrumentation initialized', {
|
|
serviceName: telemetryConfig.serviceName,
|
|
origin: MOTHERSHIP_ORIGIN,
|
|
samplingRatio,
|
|
})
|
|
} catch (error) {
|
|
logger.error('Failed to initialize OpenTelemetry instrumentation', error)
|
|
}
|
|
}
|
|
|
|
export async function register() {
|
|
await initializeOpenTelemetry()
|
|
|
|
const shutdownPostHog = async () => {
|
|
try {
|
|
const { getPostHogClient } = await import('@/lib/posthog/server')
|
|
await getPostHogClient()?.shutdown()
|
|
logger.info('PostHog client shut down successfully')
|
|
} catch (err) {
|
|
logger.error('Error shutting down PostHog client', err)
|
|
}
|
|
}
|
|
|
|
process.on('SIGTERM', shutdownPostHog)
|
|
process.on('SIGINT', shutdownPostHog)
|
|
|
|
const { startMemoryTelemetry } = await import('./lib/monitoring/memory-telemetry')
|
|
startMemoryTelemetry()
|
|
}
|