diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 1c391dfff53..26d4cd4ad42 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -157,6 +157,9 @@ export const env = createEnv({ LOG_LEVEL: z.enum(['DEBUG', 'INFO', 'WARN', 'ERROR']).optional(), // Minimum log level to display (defaults to ERROR in production, DEBUG in development) PROFOUND_API_KEY: z.string().min(1).optional(), // Profound analytics API key PROFOUND_ENDPOINT: z.string().url().optional(), // Profound analytics endpoint + GRAFANA_OTLP_ENDPOINT: z.string().url().optional(), // Grafana Cloud OTLP HTTP gateway base URL (e.g., https://otlp-gateway-prod-us-east-0.grafana.net/otlp). Trigger.dev exporters append /v1/traces, /v1/logs, /v1/metrics. + GRAFANA_OTLP_HEADERS: z.string().min(1).optional(), // Comma-separated key=value headers for OTLP requests (e.g., "Authorization=Basic "). Same format as the OTEL_EXPORTER_OTLP_HEADERS spec. + GRAFANA_DEPLOYMENT_ENVIRONMENT: z.string().min(1).optional(), // Deployment tier label (e.g., "production", "staging", "development"). Emitted as the stable `deployment.environment.name` resource attribute on Trigger.dev telemetry to match the rest of the Sim OTEL stack. // External Services BROWSERBASE_API_KEY: z.string().min(1).optional(), // Browserbase API key for browser automation diff --git a/apps/sim/package.json b/apps/sim/package.json index ecce453c21e..1026ae72ba7 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -65,6 +65,8 @@ "@modelcontextprotocol/sdk": "1.29.0", "@monaco-editor/react": "4.7.0", "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.217.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.217.0", "@opentelemetry/exporter-trace-otlp-http": "^0.217.0", "@opentelemetry/resources": "^2.7.0", "@opentelemetry/sdk-node": "^0.217.0", diff --git a/apps/sim/trigger.config.ts b/apps/sim/trigger.config.ts index 41685d0010e..1439f5af2fd 100644 --- a/apps/sim/trigger.config.ts +++ b/apps/sim/trigger.config.ts @@ -1,7 +1,64 @@ +import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http' +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http' +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' +import { resourceFromAttributes } from '@opentelemetry/resources' import { additionalFiles, additionalPackages } from '@trigger.dev/build/extensions/core' import { defineConfig } from '@trigger.dev/sdk' import { env } from './lib/core/config/env' +const grafanaEndpoint = env.GRAFANA_OTLP_ENDPOINT +const grafanaHeaders = env.GRAFANA_OTLP_HEADERS +const grafanaDeploymentEnvironment = env.GRAFANA_DEPLOYMENT_ENVIRONMENT +const grafanaConfigured = Boolean(grafanaEndpoint || grafanaHeaders || grafanaDeploymentEnvironment) +const grafanaFullyConfigured = Boolean( + grafanaEndpoint && grafanaHeaders && grafanaDeploymentEnvironment +) + +if (grafanaConfigured && !grafanaFullyConfigured) { + throw new Error( + 'Grafana OTLP telemetry is partially configured. Set GRAFANA_OTLP_ENDPOINT, GRAFANA_OTLP_HEADERS, and GRAFANA_DEPLOYMENT_ENVIRONMENT together, or leave all three unset.' + ) +} + +/** + * Parse OTLP headers per the OTEL spec format `key1=value1,key2=value2`. + * Values are URL-decoded; keys/values are trimmed; empty entries are skipped. + * @see https://opentelemetry.io/docs/specs/otel/protocol/exporter/ + */ +function parseOtlpHeaders(raw: string): Record { + const out: Record = {} + for (const pair of raw.split(',')) { + const eq = pair.indexOf('=') + if (eq === -1) continue + const key = pair.slice(0, eq).trim() + const value = pair.slice(eq + 1).trim() + if (!key) continue + out[key] = decodeURIComponent(value) + } + return out +} + +const grafanaTelemetry = grafanaFullyConfigured + ? (() => { + const baseUrl = grafanaEndpoint!.replace(/\/+$/, '') + const headers = parseOtlpHeaders(grafanaHeaders!) + if (Object.keys(headers).length === 0) { + throw new Error( + 'GRAFANA_OTLP_HEADERS is set but yielded no valid key=value pairs. Expected format: "key1=value1,key2=value2".' + ) + } + const resource = resourceFromAttributes({ + 'deployment.environment.name': grafanaDeploymentEnvironment!, + }) + return { + exporters: [new OTLPTraceExporter({ url: `${baseUrl}/v1/traces`, headers })], + logExporters: [new OTLPLogExporter({ url: `${baseUrl}/v1/logs`, headers })], + metricExporters: [new OTLPMetricExporter({ url: `${baseUrl}/v1/metrics`, headers })], + resource, + } + })() + : undefined + export default defineConfig({ project: env.TRIGGER_PROJECT_ID!, runtime: 'node-22', @@ -14,6 +71,7 @@ export default defineConfig({ }, }, dirs: ['./background'], + ...(grafanaTelemetry ? { telemetry: grafanaTelemetry } : {}), build: { external: ['isolated-vm'], extensions: [ diff --git a/bun.lock b/bun.lock index 06d495404cd..07afdcca315 100644 --- a/bun.lock +++ b/bun.lock @@ -120,6 +120,8 @@ "@modelcontextprotocol/sdk": "1.29.0", "@monaco-editor/react": "4.7.0", "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.217.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.217.0", "@opentelemetry/exporter-trace-otlp-http": "^0.217.0", "@opentelemetry/resources": "^2.7.0", "@opentelemetry/sdk-node": "^0.217.0",