import { SEMRESATTRS_PROJECT_NAME } from "@arizeai/openinference-semantic-conventions"; import { DEFAULT_PHOENIX_COLLECTOR_ENDPOINT, ENV_PHOENIX_COLLECTOR_ENDPOINT, type ResolvedTraceExportEndpoint, warnIfUsingFileEndpointWithCredentials, } from "@arizeai/phoenix-config"; import type { DiagLogLevel } from "@opentelemetry/api"; import { context, type ContextManager, diag, DiagConsoleLogger, propagation, type TextMapPropagator, trace, type TracerProvider, } from "@opentelemetry/api"; import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks"; import { CompositePropagator, W3CBaggagePropagator, W3CTraceContextPropagator, } from "@opentelemetry/core"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; import type { Instrumentation } from "@opentelemetry/instrumentation"; import { registerInstrumentations } from "@opentelemetry/instrumentation"; import { resourceFromAttributes } from "@opentelemetry/resources"; import type { SpanProcessor } from "@opentelemetry/sdk-trace-node"; import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; import { getEnvConfig, getEnvProjectName } from "./config"; import { LazyOpenInferenceSpanProcessor } from "./lazyOpenInferenceSpanProcessor"; /** * Type definition for HTTP headers used in OTLP communication * @example { "custom-header": "value", "x-api-version": "1.0" } */ export type Headers = Record; /** * Configuration parameters for registering Phoenix OpenTelemetry tracing. * * This interface defines all the available options for configuring the Phoenix * OpenTelemetry integration, including connection details, processing options, * and instrumentation settings. * * @example * ```typescript * const config: RegisterParams = { * projectName: "my-application", * url: "https://app.phoenix.arize.com", * apiKey: "your-api-key", * batch: true, * global: true * }; * ``` */ export type RegisterParams = { /** * The project name that spans will be associated with in Phoenix. * This helps organize and filter traces in the Phoenix UI. * * If not provided, the system will check the `PHOENIX_PROJECT` environment * variable (canonical) and then the `PHOENIX_PROJECT_NAME` alias, falling * back to `"default"` if neither is set. * * @default "default" * @example "my-web-app" * @example "api-service" */ projectName?: string; /** * The URL to the Phoenix server. Can be postfixed with the tracing path. * If not provided, the system will check the PHOENIX_COLLECTOR_ENDPOINT environment variable. * * The URL will be automatically normalized to include the `/v1/traces` endpoint if not present. * * @example "https://app.phoenix.arize.com" * @example "https://app.phoenix.arize.com/v1/traces" * @example "http://localhost:6006" */ url?: string; /** * The API key for authenticating with the Phoenix instance. * If not provided, the system will check the PHOENIX_API_KEY environment variable. * * The API key will be automatically added to the Authorization header as a Bearer token. * * @example "phx_1234567890abcdef" */ apiKey?: string; /** * Additional headers to be included when communicating with the OTLP collector. * These headers will be merged with any automatically generated headers (like Authorization). * * @example { "x-custom-header": "value", "x-api-version": "1.0" } */ headers?: Headers; /** * Whether to use batching for span processing. * * - `true` (default): Uses OpenInferenceBatchSpanProcessor for better performance in production * - `false`: Uses OpenInferenceSimpleSpanProcessor for immediate span export (useful for debugging) * * Batching is recommended for production environments as it reduces network overhead * and improves performance by sending multiple spans in a single request. * * @default true */ batch?: boolean; /** * A list of OpenTelemetry instrumentations to automatically register. * * **Note**: This feature may only work with CommonJS projects. ESM projects * may require manual instrumentation registration. * * @example * ```typescript * import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; * import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express'; * * const instrumentations = [ * new HttpInstrumentation(), * new ExpressInstrumentation() * ]; * ``` */ instrumentations?: Instrumentation[]; /** * Custom span processors to add to the tracer provider. * * **Important**: When provided, this will override the default span processor * created from the `url`, `apiKey`, `headers`, and `batch` parameters. * * @example * ```typescript * import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; * * const customProcessors = [ * new BatchSpanProcessor(exporter), * new MyCustomSpanProcessor() * ]; * ``` */ spanProcessors?: SpanProcessor[]; /** * Whether to register the tracer provider as the global provider. * * When `true` (default), the provider will be registered globally and can be * accessed throughout the application. Set to `false` if you want to manage * the provider lifecycle manually or use multiple providers. * * @default true */ global?: boolean; /** * The diagnostic log level for the built-in DiagConsoleLogger. * * This controls the verbosity of OpenTelemetry's internal logging. * Omit this parameter to disable built-in logging entirely. * * @example DiagLogLevel.INFO * @example DiagLogLevel.DEBUG * @example DiagLogLevel.ERROR */ diagLogLevel?: DiagLogLevel; }; export type GlobalTracerProviderRegistration = { /** * Detach the global tracer provider and reset global tracing APIs to no-op state. * * This clears the OpenTelemetry tracer provider, context manager, and propagator * so a different provider can be attached later in the same process. */ detach: () => void; }; type GlobalTelemetrySnapshot = { contextManager?: ContextManager; propagator?: TextMapPropagator; tracerProvider?: TracerProvider; }; type OpenTelemetryGlobalState = { context?: ContextManager; propagation?: TextMapPropagator; trace?: TracerProvider; version?: string; }; type GlobalTracerProviderMount = { id: number; provider: NodeTracerProvider; }; let nextGlobalTracerProviderMountId = 0; let managedGlobalBaseSnapshot: GlobalTelemetrySnapshot | null = null; const managedGlobalTracerProviderMounts: GlobalTracerProviderMount[] = []; // OpenTelemetry v1 stores globals on this well-known symbol. const OTEL_GLOBAL_SYMBOL = Symbol.for("opentelemetry.js.api.1"); function getOpenTelemetryGlobalState(): OpenTelemetryGlobalState | undefined { return ( globalThis as typeof globalThis & { [OTEL_GLOBAL_SYMBOL]?: OpenTelemetryGlobalState; } )[OTEL_GLOBAL_SYMBOL]; } function getGlobalTelemetrySnapshot(): GlobalTelemetrySnapshot { const globalState = getOpenTelemetryGlobalState(); return { tracerProvider: globalState?.trace, contextManager: globalState?.context, propagator: globalState?.propagation, }; } function clearGlobalTelemetry(): void { trace.disable(); context.disable(); propagation.disable(); } function restoreGlobalTelemetrySnapshot( snapshot: GlobalTelemetrySnapshot | null ): void { clearGlobalTelemetry(); if (!snapshot) { return; } if (snapshot.tracerProvider) { trace.setGlobalTracerProvider(snapshot.tracerProvider); } if (snapshot.contextManager) { context.setGlobalContextManager(snapshot.contextManager); } if (snapshot.propagator) { propagation.setGlobalPropagator(snapshot.propagator); } } /** * Sets the given provider as the global OpenTelemetry provider using this * module's own imports of `trace`, `context`, and `propagation`. * * We deliberately avoid calling `provider.register()` because the SDK's * internal import of `@opentelemetry/api` may resolve to a different module * instance in pnpm workspaces (different symlink paths → different module * identity). By routing all global-state mutations through **our** copy of * the OTEL API, snapshot/restore stays consistent. */ function setGlobalProvider(provider: NodeTracerProvider): void { trace.setGlobalTracerProvider(provider); const contextManager = new AsyncLocalStorageContextManager(); contextManager.enable(); context.setGlobalContextManager(contextManager); propagation.setGlobalPropagator( new CompositePropagator({ propagators: [ new W3CTraceContextPropagator(), new W3CBaggagePropagator(), ], }) ); } function restorePreviousManagedGlobalTracerProvider(): void { const previousMount = managedGlobalTracerProviderMounts[ managedGlobalTracerProviderMounts.length - 1 ]; if (previousMount) { clearGlobalTelemetry(); setGlobalProvider(previousMount.provider); return; } restoreGlobalTelemetrySnapshot(managedGlobalBaseSnapshot); managedGlobalBaseSnapshot = null; } function detachManagedGlobalTracerProvider(mountId?: number): void { if (managedGlobalTracerProviderMounts.length === 0) { clearGlobalTelemetry(); return; } const mountIndex = mountId == null ? managedGlobalTracerProviderMounts.length - 1 : managedGlobalTracerProviderMounts.findIndex( (mount) => mount.id === mountId ); if (mountIndex === -1) { return; } const isTopMount = mountIndex === managedGlobalTracerProviderMounts.length - 1; managedGlobalTracerProviderMounts.splice(mountIndex, 1); if (!isTopMount) { return; } restorePreviousManagedGlobalTracerProvider(); } /** * Detaches the current global OpenTelemetry tracer provider and resets the * global trace, context, and propagation APIs. */ export function detachGlobalTracerProvider(): void { detachManagedGlobalTracerProvider(); } /** * Attaches an existing tracer provider as the global OpenTelemetry provider. * * Returns a handle that can be used to detach it later so another provider can * be attached in the same process. */ export function attachGlobalTracerProvider( provider: NodeTracerProvider ): GlobalTracerProviderRegistration { if (managedGlobalTracerProviderMounts.length === 0) { managedGlobalBaseSnapshot = getGlobalTelemetrySnapshot(); } const mountId = nextGlobalTracerProviderMountId++; managedGlobalTracerProviderMounts.push({ id: mountId, provider, }); clearGlobalTelemetry(); setGlobalProvider(provider); return { detach: () => { detachManagedGlobalTracerProvider(mountId); }, }; } function bindGlobalTracerProviderRegistrationToShutdown({ provider, registration, }: { provider: NodeTracerProvider; registration: GlobalTracerProviderRegistration; }): void { const shutdown = provider.shutdown.bind(provider); let hasDetachedRegistration = false; provider.shutdown = async (): Promise => { if (!hasDetachedRegistration) { hasDetachedRegistration = true; registration.detach(); } await shutdown(); }; } /** * Registers Phoenix OpenTelemetry tracing with the specified configuration. * * This function sets up a complete OpenTelemetry tracing pipeline configured * to send traces to a Phoenix instance. It creates a NodeTracerProvider with * appropriate span processors, resource attributes, and optional instrumentations. * * The function handles: * - Creating and configuring a NodeTracerProvider * - Setting up OTLP trace export to Phoenix * - Configuring span processors (batch or simple) * - Registering instrumentations (if provided) * - Setting up resource attributes for project identification * - Optional global provider registration * * @param params - Configuration parameters for Phoenix tracing * @returns The configured NodeTracerProvider instance that can be used to create traces * * @example * Basic usage with minimal configuration: * ```typescript * import { register } from '@arizeai/phoenix-otel'; * * // Uses environment variables for URL and API key * const provider = register({ * projectName: 'my-application' * }); * ``` * * @example * Full configuration with custom settings: * ```typescript * import { DiagLogLevel, register } from '@arizeai/phoenix-otel'; * import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; * import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express'; * * const provider = register({ * projectName: 'my-web-app', * url: 'https://app.phoenix.arize.com', * apiKey: 'phx_1234567890abcdef', * batch: true, * global: true, * instrumentations: [ * new HttpInstrumentation(), * new ExpressInstrumentation() * ], * diagLogLevel: DiagLogLevel.INFO * }); * ``` * * @example * Custom span processors: * ```typescript * import { register } from '@arizeai/phoenix-otel'; * import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; * import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'; * * const exporter = new OTLPTraceExporter({ * url: 'https://app.phoenix.arize.com/v1/traces', * headers: { 'Authorization': 'Bearer your-api-key' } * }); * * const provider = register({ * projectName: 'my-app', * spanProcessors: [new BatchSpanProcessor(exporter)], * global: false // Manual provider management * }); * ``` * * @example * Debugging configuration: * ```typescript * import { DiagLogLevel, register } from '@arizeai/phoenix-otel'; * * const provider = register({ * projectName: 'debug-app', * url: 'http://localhost:6006', * batch: false, // Immediate span export for debugging * diagLogLevel: DiagLogLevel.DEBUG * }); * ``` */ export function register(params: RegisterParams): NodeTracerProvider { const { projectName = getEnvProjectName() ?? "default", instrumentations, global = true, diagLogLevel, spanProcessors, } = params; if (diagLogLevel) { diag.setLogger(new DiagConsoleLogger(), diagLogLevel); } const provider = new NodeTracerProvider({ resource: resourceFromAttributes({ [SEMRESATTRS_PROJECT_NAME]: projectName, }), spanProcessors: spanProcessors || [getDefaultSpanProcessor(params)], }); if (instrumentations) { registerInstrumentations({ instrumentations, tracerProvider: provider, }); } if (global) { const registration = attachGlobalTracerProvider(provider); bindGlobalTracerProviderRegistrationToShutdown({ provider, registration, }); } return provider; } /** * Creates a default span processor configured for Phoenix OpenTelemetry tracing. * * This function creates an appropriate span processor (batch or simple) based on * the provided configuration parameters. It handles URL normalization, header * configuration, and API key authentication automatically. * * The function will: * - Normalize the collector URL to include the `/v1/traces` endpoint if needed * - Configure authentication headers using the provided API key * - Merge any additional custom headers * - Create either a batch or simple span processor based on the `batch` parameter * * @param params - Configuration parameters for the span processor * @param params.url - The URL to the Phoenix server (will be normalized to include `/v1/traces`) * @param params.apiKey - The API key for authenticating with the Phoenix instance * @param params.headers - Additional headers to include in OTLP requests * @param params.batch - Whether to use batching for span processing (recommended for production) * @returns A configured SpanProcessor instance ready for use with a NodeTracerProvider * * @example * Basic usage with environment variables: * ```typescript * const processor = getDefaultSpanProcessor({ * batch: true * }); * ``` * * @example * Full configuration with custom settings: * ```typescript * const processor = getDefaultSpanProcessor({ * url: 'https://app.phoenix.arize.com', * apiKey: 'phx_1234567890abcdef', * headers: { 'x-custom-header': 'value' }, * batch: true * }); * ``` * * @example * Debugging configuration with immediate export: * ```typescript * const processor = getDefaultSpanProcessor({ * url: 'http://localhost:6006', * batch: false // Immediate span export for debugging * }); * ``` * * @example * Using with custom headers: * ```typescript * const processor = getDefaultSpanProcessor({ * url: 'https://app.phoenix.arize.com', * apiKey: 'your-api-key', * headers: { * 'x-api-version': '1.0', * 'x-client-name': 'my-app' * }, * batch: true * }); * ``` */ /** Where spans go when neither an argument nor the environment names a server. */ const DEFAULT_TRACE_EXPORT_URL = DEFAULT_PHOENIX_COLLECTOR_ENDPOINT; /** * Builds the OTLP export URL from a resolved trace-export endpoint. A value * from `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` is the traces URL already and is * used verbatim, per the OpenTelemetry specification; every other value is a * server URL that gets the `/v1/traces` path when it is missing. */ function toTraceExportUrl(endpoint: ResolvedTraceExportEndpoint): string { if (endpoint.value === undefined) { return ensureCollectorEndpoint(DEFAULT_TRACE_EXPORT_URL); } return endpoint.shape === "fully-qualified" ? new URL(endpoint.value).toString() : ensureCollectorEndpoint(endpoint.value); } const loggedTraceExportSources = new Set(); /** * Notes once which variable trace export resolved from when it resolved below * `PHOENIX_COLLECTOR_ENDPOINT`. Where spans go is worth stating plainly — the * batching exporter swallows delivery failures — but a fallback that reaches * the user's Phoenix is working as intended, not a misconfiguration. */ function logTraceExportSource({ envKey, url, }: { envKey?: string; url: string; }): void { if (!envKey || loggedTraceExportSources.has(`${envKey}\0${url}`)) { return; } loggedTraceExportSources.add(`${envKey}\0${url}`); // eslint-disable-next-line no-console console.info( `Exporting traces to ${url}, resolved from ${envKey}. ` + `Set ${ENV_PHOENIX_COLLECTOR_ENDPOINT} to export them somewhere else.` ); } /** @internal Resets the one-time trace-export source log latch for tests. */ export function resetTraceExportSourceLogForTesting(): void { loggedTraceExportSources.clear(); } export function getDefaultSpanProcessor({ url: paramsUrl, apiKey: paramsApiKey, headers: paramsHeaders = {}, batch = true, }: Pick< RegisterParams, "url" | "apiKey" | "batch" | "headers" >): SpanProcessor { const envConfig = getEnvConfig(); const usesEnvEndpoint = !paramsUrl && envConfig.endpoint.value !== undefined; let url: string; try { url = paramsUrl ? ensureCollectorEndpoint(paramsUrl) : toTraceExportUrl(envConfig.endpoint); } catch (error) { if (usesEnvEndpoint && envConfig.endpoint.source?.kind === "env-file") { // eslint-disable-next-line no-console console.warn( `Ignoring invalid ${envConfig.endpoint.envKey} value from ${envConfig.endpoint.source.filePath}: ${error instanceof Error ? error.message : "invalid URL"}.` ); url = ensureCollectorEndpoint(DEFAULT_TRACE_EXPORT_URL); } else { throw error; } } if (usesEnvEndpoint && envConfig.endpoint.rank !== "canonical") { logTraceExportSource({ envKey: envConfig.endpoint.envKey, url }); } const apiKey = paramsApiKey || envConfig.credentials.apiKey; const explicitHeaders: Headers = Array.isArray(paramsHeaders) ? Object.fromEntries(paramsHeaders) : { ...paramsHeaders }; const headers: Headers = { ...envConfig.credentials.headers }; for (const [key, value] of Object.entries(explicitHeaders)) { const existingKey = Object.keys(headers).find( (header) => header.toLowerCase() === key.toLowerCase() ); if (existingKey) { delete headers[existingKey]; } headers[key] = value; } const hasExplicitAuthorization = Object.keys(explicitHeaders).some( (key) => key.toLowerCase() === "authorization" ); if (typeof paramsApiKey === "string" && !hasExplicitAuthorization) { for (const key of Object.keys(headers)) { if (key.toLowerCase() === "authorization") { delete headers[key]; } } } const hasExplicitCredentials = typeof paramsApiKey === "string" || Object.keys(explicitHeaders).length > 0; const credentialSource = hasExplicitCredentials ? "explicit arguments" : envConfig.credentials.source?.kind === "process" ? "the process environment" : undefined; if (usesEnvEndpoint) { warnIfUsingFileEndpointWithCredentials({ credentialSource, endpointSource: envConfig.endpoint.source, endpointVariable: envConfig.endpoint.envKey ?? ENV_PHOENIX_COLLECTOR_ENDPOINT, }); } const configureHeaders = typeof apiKey === "string" && !Object.keys(headers).some((key) => key.toLowerCase() === "authorization"); if (configureHeaders) { headers["authorization"] = `Bearer ${apiKey}`; } const exporter = new OTLPTraceExporter({ url, headers, }); // The OpenInference span processors come from the ESM-only // `@arizeai/openinference-vercel` package; the lazy processor loads them // with a dynamic import so this module stays loadable from CommonJS. const spanProcessor: SpanProcessor = new LazyOpenInferenceSpanProcessor({ exporter, batch, }); return spanProcessor; } /** * Normalizes a Phoenix server URL to ensure it includes the correct OTLP traces endpoint. * * This utility function ensures that any Phoenix server URL is properly formatted * to include the `/v1/traces` endpoint required for OTLP trace export. It handles * various URL formats and automatically appends the endpoint if missing. * * The function: * - Checks if the URL already contains `/v1/traces` * - If missing, appends `/v1/traces` to the base URL * - Returns a properly formatted URL string * - The `/v1/traces` path is the standard endpoint for OTLP/HTTP trace export * * @param url - The base URL to the Phoenix server (may or may not include the traces endpoint) * @returns A normalized URL string that includes the `/v1/traces` endpoint * * @example * URL without traces endpoint: * ```typescript * const normalized = ensureCollectorEndpoint('https://app.phoenix.arize.com'); * // Returns: 'https://app.phoenix.arize.com/v1/traces' * ``` * * @example * URL that already includes traces endpoint: * ```typescript * const normalized = ensureCollectorEndpoint('https://app.phoenix.arize.com/v1/traces'); * // Returns: 'https://app.phoenix.arize.com/v1/traces' * ``` * * @example * Local development URL: * ```typescript * const normalized = ensureCollectorEndpoint('http://localhost:6006'); * // Returns: 'http://localhost:6006/v1/traces' * ``` * * @example * URL with custom port and path: * ```typescript * const normalized = ensureCollectorEndpoint('https://phoenix.example.com:8080'); * // Returns: 'https://phoenix.example.com:8080/v1/traces' * ``` * * @example * URL with existing path (edge case): * ```typescript * const normalized = ensureCollectorEndpoint('https://app.phoenix.arize.com/api'); * // Returns: 'https://app.phoenix.arize.com/api/v1/traces' * ``` * * @example * A doubled separator or trailing slash on the traces path is canonicalized: * ```typescript * const normalized = ensureCollectorEndpoint('https://app.phoenix.arize.com/v1/traces/'); * // Returns: 'https://app.phoenix.arize.com/v1/traces' * ``` */ export function ensureCollectorEndpoint(url: string): string { const collectorUrl = new URL(url); const { pathname } = collectorUrl; if (OTLP_TRACES_PATH_SUFFIX.test(pathname)) { // Already the traces path: canonicalize a doubled separator or trailing // slash so exporters hit the route directly instead of a redirect. collectorUrl.pathname = `${pathname.replace(OTLP_TRACES_PATH_SUFFIX, "")}/v1/traces`; } else if (!pathname.includes("/v1/traces")) { // A path that merely contains the traces path — a gateway route such as // `/v1/traces/tenant-a` — is left alone; appending would break it. collectorUrl.pathname = `${pathname.replace(/\/+$/, "")}/v1/traces`; } return collectorUrl.toString(); } /** * The OTLP traces path as a suffix of a URL's path component. A repeated * separator (`…//v1/traces`) and a trailing slash both match; the match is * case-sensitive because URL paths are, so `/V1/traces` is treated as an * ordinary path rather than silently rewritten. */ const OTLP_TRACES_PATH_SUFFIX = /\/+v1\/traces\/?$/;