From a1930f92025f4402ca7a25cbbfb3bc907d4ea099 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Mon, 13 Jul 2026 23:27:57 +0800 Subject: [PATCH] Add span origin provenance --- integrations/otel-js/src/otel.ts | 57 ++++++++++++++++ js/src/logger.ts | 16 ++++- js/src/node/config.ts | 40 ++++++++++- js/src/span-origin.ts | 111 +++++++++++++++++++++++++++++++ 4 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 js/src/span-origin.ts diff --git a/integrations/otel-js/src/otel.ts b/integrations/otel-js/src/otel.ts index e588248d5..f1c7e9562 100644 --- a/integrations/otel-js/src/otel.ts +++ b/integrations/otel-js/src/otel.ts @@ -192,6 +192,7 @@ interface BraintrustSpanProcessorOptions { * Additional headers to send with telemetry data */ headers?: Record; + environment?: { type: string; name?: string }; /** * @internal * Internal option for dependency injection during testing. @@ -200,6 +201,47 @@ interface BraintrustSpanProcessorOptions { _spanProcessor?: SpanProcessor; } +const SDK_VERSION = "3.20.0"; + +function spanOriginContext(environment?: { type: string; name?: string }) { + return { + span_origin: { + name: "braintrust.sdk.javascript", + version: SDK_VERSION, + instrumentation: { name: "braintrust-otel-js" }, + ...(environment ? { environment } : {}), + }, + }; +} + +function detectEnvironment( + explicit?: { type: string; name?: string }, +): { type: string; name?: string } | undefined { + if (explicit) return explicit; + const envType = process.env.BRAINTRUST_ENVIRONMENT_TYPE; + if (envType) { + const envName = process.env.BRAINTRUST_ENVIRONMENT_NAME; + return envName ? { type: envType, name: envName } : { type: envType }; + } + if (process.env.GITHUB_ACTIONS) return { type: "ci", name: "github_actions" }; + if (process.env.GITLAB_CI) return { type: "ci", name: "gitlab_ci" }; + if (process.env.CIRCLECI) return { type: "ci", name: "circleci" }; + if (process.env.BUILDKITE) return { type: "ci", name: "buildkite" }; + if (process.env.CI) return { type: "ci", name: "ci" }; + if (process.env.VERCEL) return { type: "server", name: "vercel" }; + if (process.env.NETLIFY) return { type: "server", name: "netlify" }; + if (process.env.AWS_LAMBDA_FUNCTION_NAME || process.env.AWS_EXECUTION_ENV) { + return { type: "server", name: "aws_lambda" }; + } + if (process.env.NODE_ENV === "production" || process.env.NODE_ENV === "staging") { + return { type: "server", name: process.env.NODE_ENV }; + } + if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "local") { + return { type: "local", name: process.env.NODE_ENV }; + } + return undefined; +} + class LazyBraintrustOTLPTraceExporter implements SpanExporter { private readonly diagLogger = diag.createComponentLogger({ namespace: "@braintrust/otel", @@ -377,8 +419,10 @@ export class BraintrustSpanProcessor implements SpanProcessor { private readonly processor: SpanProcessor; private readonly aiSpanProcessor: SpanProcessor; private readonly exporter?: LazyBraintrustOTLPTraceExporter; + private readonly environment?: { type: string; name?: string }; constructor(options: BraintrustSpanProcessorOptions = {}) { + this.environment = detectEnvironment(options.environment); // If a processor is injected (for testing), use it directly if (options._spanProcessor) { this.processor = options._spanProcessor; @@ -496,6 +540,19 @@ export class BraintrustSpanProcessor implements SpanProcessor { span.setAttributes?.({ "braintrust.parent": parentValue }); } } + + const contextJson = JSON.stringify(spanOriginContext(this.environment)); + span.setAttributes?.({ + "braintrust.context_json": contextJson, + ...(this.environment + ? { + "braintrust.environment.type": this.environment.type, + ...(this.environment.name + ? { "braintrust.environment.name": this.environment.name } + : {}), + } + : {}), + }); } catch { // If there's an exception, just don't set braintrust.parent } diff --git a/js/src/logger.ts b/js/src/logger.ts index 6d2812cf4..fb3ff1df1 100644 --- a/js/src/logger.ts +++ b/js/src/logger.ts @@ -165,6 +165,11 @@ import { lintTemplate as _lintMustacheTemplate } from "./template/mustache-utils import { prettifyXact } from "../util/index"; import { SpanCache, CachedSpan } from "./span-cache"; import type { EvalParameters, InferParameters } from "./eval-parameters"; +import { + detectSpanOriginEnvironment, + mergeSpanOriginContext, + type SpanOriginEnvironment, +} from "./span-origin"; // Manual type definition for inline attachments (not in generated_types) const InlineAttachmentReferenceSchema = z.object({ @@ -677,6 +682,7 @@ export class BraintrustState { private _idGenerator: IDGenerator | null = null; private _contextManager: ContextManager | null = null; private _otelFlushCallback: (() => Promise) | null = null; + public spanOriginEnvironment: SpanOriginEnvironment | undefined; constructor(private loginParams: LoginOptions) { this.id = `${new Date().toLocaleString()}-${stateNonce++}`; // This is for debugging. uuidv4() breaks on platforms like Cloudflare. @@ -736,6 +742,7 @@ export class BraintrustState { }); this.spanCache = new SpanCache({ disabled: loginParams.disableSpanCache }); + this.spanOriginEnvironment = detectSpanOriginEnvironment(); } public resetLoginInfo() { @@ -4398,6 +4405,7 @@ type AsyncFlushArg = { export type InitLoggerOptions = FullLoginOptions & { projectName?: string; projectId?: string; + environment?: SpanOriginEnvironment; setCurrent?: boolean; state?: BraintrustState; orgProjectMetadata?: OrgProjectMetadata; @@ -4431,6 +4439,7 @@ export function initLogger( orgName, forceLogin, debugLogLevel, + environment, fetch, state: stateArg, } = options || {}; @@ -4452,6 +4461,7 @@ export function initLogger( const state = stateArg ?? _globalState; state.setDebugLogLevel(debugLogLevel); + state.spanOriginEnvironment = detectSpanOriginEnvironment(environment); // Enable queue size limit enforcement for initLogger() calls // This ensures production observability doesn't OOM customer processes @@ -6854,7 +6864,11 @@ export class SpanImpl implements Span { metrics: { start: args.startTime ?? getCurrentUnixTimestamp(), }, - context: { ...callerLocation }, + context: mergeSpanOriginContext( + { ...callerLocation }, + "braintrust-js-logger", + this._state.spanOriginEnvironment, + ), span_attributes: { name, type, diff --git a/js/src/node/config.ts b/js/src/node/config.ts index 09c27b807..b7c8d98b6 100644 --- a/js/src/node/config.ts +++ b/js/src/node/config.ts @@ -31,7 +31,17 @@ export function configureNode() { iso.getPastNAncestors = getPastNAncestors; iso.getEnv = (name) => { const value = process.env[name]; - return name === "BRAINTRUST_API_KEY" && !value?.trim() ? undefined : value; + if (name === "BRAINTRUST_API_KEY") { + return value?.trim() ? value : undefined; + } + if ( + (name === "BRAINTRUST_ENVIRONMENT_TYPE" || + name === "BRAINTRUST_ENVIRONMENT_NAME") && + !value?.trim() + ) { + return getNearestBraintrustEnvValue(name); + } + return value; }; iso.getBraintrustApiKey = async () => { const value = process.env.BRAINTRUST_API_KEY; @@ -152,3 +162,31 @@ export function configureNode() { // Enable auto-instrumentation registry.enable(); } + +function getNearestBraintrustEnvValue(name: string): string | undefined { + for ( + let dir = process.cwd(), depth = 0; + depth <= BRAINTRUST_ENV_SEARCH_PARENT_LIMIT; + dir = path.dirname(dir), depth++ + ) { + const envPath = path.join(dir, ".env.braintrust"); + try { + const parsed = dotenv.parse(fsSync.readFileSync(envPath, "utf8")); + const value = parsed[name]; + return value?.trim() ? value : undefined; + } catch (e) { + if ( + typeof e !== "object" || + e === null || + !("code" in e) || + e.code !== "ENOENT" + ) { + return undefined; + } + } + if (path.dirname(dir) === dir) { + break; + } + } + return undefined; +} diff --git a/js/src/span-origin.ts b/js/src/span-origin.ts new file mode 100644 index 000000000..7b1914586 --- /dev/null +++ b/js/src/span-origin.ts @@ -0,0 +1,111 @@ +import iso from "./isomorph"; + +export type SpanOriginEnvironment = { + type: string; + name?: string; +}; + +export type SpanOrigin = { + name: string; + version: string; + instrumentation: { name: string }; + environment?: SpanOriginEnvironment; +}; + +const SDK_VERSION = "3.20.0"; + +export function detectSpanOriginEnvironment( + explicit?: SpanOriginEnvironment, +): SpanOriginEnvironment | undefined { + if (explicit) return explicit; + + const envType = iso.getEnv("BRAINTRUST_ENVIRONMENT_TYPE"); + if (envType) { + const envName = iso.getEnv("BRAINTRUST_ENVIRONMENT_NAME"); + return envName ? { type: envType, name: envName } : { type: envType }; + } + + const ci = firstPresent([ + ["GITHUB_ACTIONS", "github_actions"], + ["GITLAB_CI", "gitlab_ci"], + ["CIRCLECI", "circleci"], + ["BUILDKITE", "buildkite"], + ["JENKINS_URL", "jenkins"], + ["JENKINS_HOME", "jenkins"], + ["TF_BUILD", "azure_pipelines"], + ["TEAMCITY_VERSION", "teamcity"], + ["TRAVIS", "travis"], + ["BITBUCKET_BUILD_NUMBER", "bitbucket"], + ]); + if (ci) return { type: "ci", name: ci }; + if (iso.getEnv("CI")) return { type: "ci", name: "ci" }; + + const server = firstPresent([ + ["VERCEL", "vercel"], + ["NETLIFY", "netlify"], + ["AWS_LAMBDA_FUNCTION_NAME", "aws_lambda"], + ["AWS_EXECUTION_ENV", "aws_lambda"], + ["K_SERVICE", "cloud_run"], + ["FUNCTION_TARGET", "gcp_functions"], + ["KUBERNETES_SERVICE_HOST", "kubernetes"], + ["ECS_CONTAINER_METADATA_URI", "ecs"], + ["ECS_CONTAINER_METADATA_URI_V4", "ecs"], + ["DYNO", "heroku"], + ["FLY_APP_NAME", "fly"], + ["RAILWAY_ENVIRONMENT", "railway"], + ["RENDER_SERVICE_NAME", "render"], + ]); + if (server) return { type: "server", name: server }; + + return deploymentModeEnvironment("NODE_ENV", iso.getEnv("NODE_ENV")); +} + +export function makeSpanOrigin( + instrumentationName: string, + environment?: SpanOriginEnvironment, +): SpanOrigin { + return { + name: "braintrust.sdk.javascript", + version: SDK_VERSION, + instrumentation: { name: instrumentationName }, + ...(environment ? { environment } : {}), + }; +} + +export function mergeSpanOriginContext( + context: Record | undefined, + instrumentationName: string, + environment?: SpanOriginEnvironment, +): Record { + const next = { ...(context ?? {}) }; + const current = + isObject(next.span_origin) ? { ...next.span_origin } : {}; + next.span_origin = { + ...makeSpanOrigin(instrumentationName, environment), + ...current, + }; + return next; +} + +function firstPresent(entries: Array<[string, string]>): string | undefined { + return entries.find(([key]) => Boolean(iso.getEnv(key)))?.[1]; +} + +function deploymentModeEnvironment( + _key: string, + value: string | undefined, +): SpanOriginEnvironment | undefined { + if (!value) return undefined; + const normalized = value.toLowerCase(); + if (normalized === "production" || normalized === "staging") { + return { type: "server", name: normalized }; + } + if (normalized === "development" || normalized === "local") { + return { type: "local", name: normalized }; + } + return undefined; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +}