diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 786606a..fdb2465 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,3 +56,15 @@ jobs: run: | test -f dist/index.js || (echo "dist/index.js missing" && exit 1) test -f dist/index.d.ts || (echo "dist/index.d.ts missing" && exit 1) + # PHOTON_OTEL_VERSION is inlined from package.json at build time. Assert on + # the built artifact, because that is what the release pipeline publishes + # after the bot bumps package.json. + - name: Verify dist reports the package version + run: | + bun -e ' + const { PHOTON_OTEL_VERSION } = await import("./dist/index.js"); + const { version } = await import("./package.json"); + if (PHOTON_OTEL_VERSION !== version) { + throw new Error(`dist reports ${PHOTON_OTEL_VERSION}, package.json says ${version}`); + } + ' diff --git a/README.md b/README.md index a4cd459..e695bc2 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A DX-focused OpenTelemetry wrapper for **Bun** and **Node.js**. Vanilla OTel works, but the setup is verbose, the logger plumbing is awkward, and PII scrubbing is on you. `@photon-ai/otel` wraps the OTLP/HTTP stack into a few well-named functions: - **`setupOtel()`** — idempotent one-call bootstrap for traces + logs + metrics. Honors standard `OTEL_EXPORTER_OTLP_*` env vars. -- **`setupOptionOtel()`** — creates an isolated trace + log runtime that reuses the main Resource without replacing global providers or context. +- **`createIsolatedOtel()`** — creates an isolated trace + log runtime with its own Resource and configurable propagation header, without replacing global providers or context. - **`otel.getMeter(name)`** — creates standard OpenTelemetry instruments from this setup's meter provider, with identical behavior in global and scoped mode. - **`createLogger(module)`** — structured logger that writes to both the OTel logger provider and `console`, with automatic trace correlation and exception capture. Every level (`debug`/`info`/`warn`/`error`) accepts `attrs` **and** an `error`, and shares one configurable level gate. - **`withSpan(name, attrs?, fn)`** — wrap any sync or async function in a span; errors are recorded and PII in the error message is scrubbed before being attached to span status. @@ -95,7 +95,7 @@ attribute guidance, and scoped mode. | Function | Description | | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `setupOtel(options): OtelHandle` | Boots OTLP/HTTP traces + logs + metrics. The handle exposes `getMeter()`, providers, and `shutdown()`. Pass `register: false` for scoped mode. | -| `setupOptionOtel(options): OptionOtelHandle` | Creates an isolated, non-global trace + log runtime after `setupOtel()`. Requires its own endpoint and reuses the main Resource. | +| `createIsolatedOtel(options): IsolatedOtelHandle` | Creates an isolated, non-global trace + log runtime with its own endpoint, Resource, and propagation header. Returns a new runtime on every call. | | `isOtelActive(): boolean` | Returns `true` if `setupOtel` has already run in this process. | | `instrumentFetch(options?): FetchInstrumentation` | Low-level wrap of `globalThis.fetch` for CLIENT spans + W3C propagation. Returns `{ unpatch() }`. `setupOtel` calls this on Bun; on Node it prefers native undici. | | `createInstrumentedFetch(baseFetch?, options?): typeof fetch` | Returns a NEW instrumented fetch (CLIENT spans + W3C propagation) wrapping `baseFetch` (default `globalThis.fetch`) without touching the global. For SDKs that take a `fetch` option. | @@ -109,47 +109,42 @@ attribute guidance, and scoped mode. | `sanitizeErrorMessage(input)` | Masks every email and phone embedded in a free-form string. | | `PHOTON_OTEL_VERSION` | Constant — current package version. | -## Isolated option runtime +## Isolated runtime -Use `setupOptionOtel()` when one process must send a small, explicitly recorded -trace/log stream to a different OTLP backend without taking over the main OTel -runtime: +Use `createIsolatedOtel()` when one process must send a small, explicitly +recorded trace/log stream to a different OTLP backend without taking over the +main OTel runtime: ```ts -import { setupOptionOtel, setupOtel } from "@photon-ai/otel"; +import { createIsolatedOtel } from "@photon-ai/otel"; -const mainOtel = setupOtel({ - endpoint: "https://observability.example.com", - serviceName: "projects-service", +const auditOtel = createIsolatedOtel({ + endpoint: "https://audit-collector.example.com", + serviceName: "audit-service", + serviceVersion: "1.2.3", + traceparentHeader: "x-audit-traceparent", }); +const auditLogger = auditOtel.createLogger("example.audit"); -const developerOtel = setupOptionOtel({ - endpoint: "https://developer-collector.example.com", - headers: { Authorization: "Bearer token" }, +await auditOtel.withSpan("audit.write", async () => { + auditLogger.emit({ body: "writing audit entry" }); }); -const developerLogger = developerOtel.createLogger( - "@photon-ai/developer-logs" -); -await developerOtel.withSpan("project.generate", async () => { - developerLogger.emit({ body: "starting project generation" }); -}); - -await developerOtel.shutdown(); -await mainOtel.shutdown(); +await auditOtel.shutdown(); ``` -The option runtime has its own providers, processors, exporters, and -`AsyncLocalStorage` context. It does not register globals, instrument fetch, or -read the main `OTEL_EXPORTER_OTLP_*` exporter variables. It does reuse the -Resource already resolved by `setupOtel()`, so `serviceName` is intentionally -absent from its options and `service.name` is still exported. +`serviceName` / `serviceVersion` / `resourceAttributes` work exactly as they do +in `setupOtel()`. Unlike `setupOtel()`, this is a plain factory rather than a +process-wide singleton: each call returns a new independent runtime that you own +and shut down yourself. + +The isolated runtime has its own providers, processors, exporters, Resource, and +`AsyncLocalStorage` context. It does not register globals, instrument fetch, +read the main `OTEL_EXPORTER_OTLP_*` variables, add `deployment.environment`, or +require `setupOtel()`. Its propagation helper carries only its isolated trace context through the -library-fixed `photon-developer-traceparent` header. The header name is not -configurable. Application-facing Developer Logs APIs should wrap the lower-level -`createLogger()` and propagation methods rather than exposing IDs or Context to -business code. +configured `traceparentHeader`; it never changes the standard `traceparent`. ### Logger signatures diff --git a/docs/concepts/architecture.mdx b/docs/concepts/architecture.mdx index dc63cd2..befdca2 100644 --- a/docs/concepts/architecture.mdx +++ b/docs/concepts/architecture.mdx @@ -11,7 +11,8 @@ It does not invent a telemetry model. It chooses a small set of defaults that ma The package exports one entry point with these groups: -- setup: `setupOtel()`, `setupOptionOtel()`, `isOtelActive()`, and the returned handles +- setup: `setupOtel()`, `isOtelActive()`, and the returned handle +- isolated runtime: `createIsolatedOtel()` and its handle - logging: `createLogger()`, `setLogLevel()`, `getLogLevel()` - tracing helper: `withSpan()` - fetch instrumentation: `instrumentFetch()` @@ -57,11 +58,11 @@ The returned handle's `getMeter()` method calls its configured meter provider directly. This keeps metrics scoped to that setup when `register: false`; application code does not need to know which registration mode is active. -`setupOptionOtel()` is a stricter instance path for a second explicit trace and -log stream. It uses its own providers and `AsyncLocalStorage` context and never -registers them globally. Only the already-resolved Resource is shared with the -main runtime, so the two pipelines describe the same process without sharing -active spans or exporters. +`createIsolatedOtel()` is an instance path for a second explicit trace and log +stream. It uses its own Resource, providers, and `AsyncLocalStorage` context +and never registers them globally. It is a plain factory, not a singleton: +each call builds a new runtime. The two pipelines share no active spans, +exporters, or Resource metadata. ## Why setup is idempotent diff --git a/docs/guides/isolated-runtime.mdx b/docs/guides/isolated-runtime.mdx new file mode 100644 index 0000000..d57bbdf --- /dev/null +++ b/docs/guides/isolated-runtime.mdx @@ -0,0 +1,74 @@ +--- +title: "Isolated runtime" +description: "Run a second explicit trace and log pipeline without replacing the main OpenTelemetry runtime." +--- + +`createIsolatedOtel()` creates an independent instance-level trace and log +runtime: + +```ts +import { createIsolatedOtel } from "@photon-ai/otel"; + +const isolated = createIsolatedOtel({ + endpoint: "https://audit-collector.example.com", + serviceName: "audit-service", + serviceVersion: "1.2.3", + traceparentHeader: "x-audit-traceparent", +}); +``` + +The isolated runtime owns a separate `BasicTracerProvider`, `LoggerProvider`, +processors, exporters, and async context. It does not register any global +provider or context manager, patch fetch, configure metrics, or change the +main runtime. + +Unlike `setupOtel()`, which is an idempotent process-wide singleton, this is a +plain factory: every call returns a new independent runtime that the caller owns +and shuts down. + +It creates a separate Resource from `serviceName`, `serviceVersion`, and +`resourceAttributes` — the same identity options `setupOtel()` takes. It neither +copies metadata from the main runtime nor requires the main runtime to exist, +and it does not add `deployment.environment`; pass that through +`resourceAttributes` if your queries need it. + +## Explicit spans and logs + +```ts +const logger = isolated.createLogger("example.audit"); + +await isolated.withSpan( + "report.generate", + { "app.entity.id": entityId }, + async () => { + logger.emit({ + body: "starting report generation", + eventName: "audit.message", + }); + } +); +``` + +Success leaves the Span status `UNSET`. If the callback throws, the runtime +records a standard exception, marks the Span `ERROR`, ends it, and rethrows the +same value. Export and processor failures do not replace the business result. + +The lower-level logger accepts OpenTelemetry `LogRecord` fields. A domain SDK +should normally wrap it and define its own message/severity contract. + +## Propagation + +The handle exposes `capture()`, `inject()`, `extract()`, and `run()` through +`isolated.propagation`. They operate only on this runtime's async context +and use the configured `traceparentHeader`. Its value uses W3C Trace Context +encoding; the standard main `traceparent` remains untouched. + +## Shutdown + +Shut down the instance runtime during application shutdown: + +```ts +await isolated.shutdown(); +``` + +Shutdown disables only this runtime's async context and providers. diff --git a/docs/guides/option-runtime.mdx b/docs/guides/option-runtime.mdx deleted file mode 100644 index 8c7557d..0000000 --- a/docs/guides/option-runtime.mdx +++ /dev/null @@ -1,75 +0,0 @@ ---- -title: "Isolated option runtime" -description: "Run a second explicit trace and log pipeline without replacing the main OpenTelemetry runtime." ---- - -`setupOptionOtel()` creates an instance-level trace and log runtime after the -main `setupOtel()` has initialized the process: - -```ts -import { setupOptionOtel, setupOtel } from "@photon-ai/otel"; - -const main = setupOtel({ - endpoint: "https://observability.example.com", - serviceName: "projects-service", -}); - -const option = setupOptionOtel({ - endpoint: "https://developer-collector.example.com", - headers: { Authorization: "Bearer token" }, -}); -``` - -The option runtime owns a separate `BasicTracerProvider`, `LoggerProvider`, -processors, exporters, and async context. It does not register any global -provider or context manager, patch fetch, configure metrics, or change the -main runtime. - -It reuses the Resource already resolved by `setupOtel()`. This keeps -`service.name`, `service.version`, deployment metadata, and application -Resource attributes consistent while avoiding a second service-name option. -Calling it before `setupOtel()` is a configuration error. - -## Explicit spans and logs - -```ts -const logger = option.createLogger("@photon-ai/developer-logs"); - -await option.withSpan( - "project.generate", - { "photon.project.id": projectId }, - async () => { - logger.emit({ - body: "starting project generation", - eventName: "developer.message", - }); - } -); -``` - -Success leaves the Span status `UNSET`. If the callback throws, the runtime -records a standard exception, marks the Span `ERROR`, ends it, and rethrows the -same value. Export and processor failures do not replace the business result. - -The lower-level logger accepts OpenTelemetry `LogRecord` fields. A domain SDK -should normally wrap it and define its own message/severity contract. - -## Propagation - -The handle exposes `capture()`, `inject()`, `extract()`, and `run()` through -`option.propagation`. They operate only on the option runtime's async context -and use the fixed `photon-developer-traceparent` carrier header. The header -value uses W3C Trace Context encoding; the standard main `traceparent` remains -untouched. - -## Shutdown - -Shut down instance runtimes before the main runtime: - -```ts -await option.shutdown(); -await main.shutdown(); -``` - -Option shutdown disables only its async context and providers. The main -runtime remains active. diff --git a/docs/reference/api.mdx b/docs/reference/api.mdx index 9c1f275..fe4a9d4 100644 --- a/docs/reference/api.mdx +++ b/docs/reference/api.mdx @@ -8,6 +8,7 @@ Import everything from the package root: ```ts import { createInstrumentedFetch, + createIsolatedOtel, createLogger, getLogLevel, instrumentFetch, @@ -17,7 +18,6 @@ import { sanitizePhone, sanitizeUrl, setLogLevel, - setupOptionOtel, setupOtel, withSpan, } from "@photon-ai/otel"; @@ -39,7 +39,7 @@ interface SetupOtelOptions { serviceVersion?: string; endpoint?: string; headers?: Record; - resourceAttributes?: Record; + resourceAttributes?: Attributes; logLevel?: LogLevel; instrumentFetch?: boolean | InstrumentFetchOptions; register?: boolean; @@ -88,27 +88,40 @@ Create meters and instruments after setup. There is intentionally no top-level m `shutdown()` unpatches fetch instrumentation if setup installed it, shuts down all three providers, and clears the active setup handle. -## `setupOptionOtel(options)` +## `createIsolatedOtel(options)` -Creates an isolated, non-global trace and log runtime. The main `setupOtel()` -must already be active. +Creates an isolated, non-global trace and log runtime. It does not depend on +the main `setupOtel()` runtime. ```ts -function setupOptionOtel(options: SetupOptionOtelOptions): OptionOtelHandle; +function createIsolatedOtel(options: IsolatedOtelOptions): IsolatedOtelHandle; -interface SetupOptionOtelOptions { +interface IsolatedOtelOptions { endpoint: string; + traceparentHeader: string; + serviceName: string; + serviceVersion?: string; + resourceAttributes?: Attributes; headers?: Record; } ``` +Unlike `setupOtel()`, this is not idempotent and holds no process state: each +call returns a new independent runtime that the caller owns and shuts down. + The endpoint is required and is not overridden by the main standard exporter environment variables. `/v1/traces` and `/v1/logs` are appended to a base -endpoint. The runtime reuses the main Resource; it intentionally has no -`serviceName` option. +endpoint. `traceparentHeader` must be a private carrier name and cannot be the +standard `traceparent` header. + +The runtime builds its own Resource from `serviceName`, `serviceVersion`, and +`resourceAttributes`, which behave exactly as in `setupOtel()` — an explicit +`resourceAttributes` entry overrides the value derived from `serviceName` or +`serviceVersion`. It copies nothing from the main runtime and does not add +`deployment.environment`. ```ts -interface OptionOtelHandle { +interface IsolatedOtelHandle { createLogger(name: string, version?: string): { emit(record: Omit): void; }; @@ -139,9 +152,8 @@ OpenTelemetry `SpanOptions`, an optional explicit parent Context, and exposes the active Span to the callback. A suppressed parent produces no recording Span and does not make `hasActiveSpan()` true; the business callback still runs. -The propagation helper uses the fixed internal -`photon-developer-traceparent` header and never changes the standard -`traceparent`. +The propagation helper uses the configured `traceparentHeader` and never +changes the standard `traceparent`. ## `isOtelActive()` @@ -421,3 +433,5 @@ const PHOTON_OTEL_VERSION: string; ``` It is used as the instrumentation scope version for package-created tracers and loggers. + +The value is read from `package.json` and inlined at build time, so it always matches the published version — the release pipeline bumps `package.json` alone and never needs a matching source edit. diff --git a/src/index.ts b/src/index.ts index 19515e6..fc27b0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,11 @@ export { type InstrumentFetchOptions, instrumentFetch, } from "./instrument-fetch"; +export { + createIsolatedOtel, + type IsolatedOtelHandle, + type IsolatedOtelOptions, +} from "./isolated-runtime"; export { createLogger, getLogLevel, @@ -14,11 +19,6 @@ export { type PhotonLogger, setLogLevel, } from "./logger"; -export { - type OptionOtelHandle, - type SetupOptionOtelOptions, - setupOptionOtel, -} from "./option-runtime"; export { type SanitizeUrlOptions, sanitizeEmail, diff --git a/src/option-runtime.ts b/src/isolated-runtime.ts similarity index 72% rename from src/option-runtime.ts rename to src/isolated-runtime.ts index a27d753..8151b43 100644 --- a/src/option-runtime.ts +++ b/src/isolated-runtime.ts @@ -17,7 +17,10 @@ import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-ho import { W3CTraceContextPropagator } from "@opentelemetry/core"; import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; -import type { Resource } from "@opentelemetry/resources"; +import { + type Resource, + resourceFromAttributes, +} from "@opentelemetry/resources"; import { BatchLogRecordProcessor, LoggerProvider, @@ -29,25 +32,34 @@ import { type SpanProcessor, } from "@opentelemetry/sdk-trace-base"; import { resolveOtlpEndpoint } from "./otlp-config"; -import { activeOtelResource } from "./setup"; +import { + type ServiceResourceOptions, + serviceResourceAttributes, +} from "./service-resource"; -const DEVELOPER_TRACEPARENT_HEADER = "photon-developer-traceparent"; -const DEVELOPER_INSTRUMENTATION_SCOPE = "@photon-ai/developer-logs"; +const INSTRUMENTATION_SCOPE = "@photon-ai/otel"; const TRACEPARENT_KEY = "traceparent"; -const DIAGNOSTIC_SCOPE = "@photon-ai/otel.option-runtime"; -export interface SetupOptionOtelOptions { +// biome-ignore assist/source/useSortedInterfaceMembers: required options precede optional configuration. +export interface IsolatedOtelOptions extends ServiceResourceOptions { /** - * Developer OTLP/HTTP base endpoint. `/v1/traces` and `/v1/logs` are - * appended by the runtime. Standard main OTel environment variables do not - * override this value. + * OTLP/HTTP base endpoint. `/v1/traces` and `/v1/logs` are appended by the + * runtime. Standard main OTel environment variables do not override it. */ endpoint: string; + /** Private carrier header; the standard `traceparent` name is rejected. */ + traceparentHeader: string; /** Optional OTLP transport headers, typically used for Collector auth. */ headers?: Record; } -export interface OptionOtelHandle { +/** Transport-only options; the Resource is supplied separately. */ +type IsolatedOtelTransport = Pick< + IsolatedOtelOptions, + "endpoint" | "headers" | "traceparentHeader" +>; + +export interface IsolatedOtelHandle { createLogger( name: string, version?: string @@ -59,9 +71,9 @@ export interface OptionOtelHandle { readonly propagation: { /** Capture the current context so delayed iterators can re-enter it. */ capture: () => Context; - /** Extract a valid Developer parent from the fixed internal header. */ + /** Extract a valid parent from this runtime's configured carrier header. */ extract: (headers: Headers) => Context | undefined; - /** Inject the current or explicitly captured Developer context. */ + /** Inject the current or explicitly captured private context. */ inject: (headers: Headers, captured?: Context) => void; /** Run a callback in this runtime's isolated async context. */ run: (captured: Context, fn: () => T) => T; @@ -84,10 +96,10 @@ export interface OptionOtelHandle { const reportDiagnostic = (message: string, error?: unknown): void => { try { if (error === undefined) { - diag.warn(`[${DIAGNOSTIC_SCOPE}] ${message}`); + diag.warn(`[${INSTRUMENTATION_SCOPE}] ${message}`); return; } - diag.warn(`[${DIAGNOSTIC_SCOPE}] ${message}`, error); + diag.warn(`[${INSTRUMENTATION_SCOPE}] ${message}`, error); } catch { // Diagnostic reporting is itself fail-open. } @@ -97,23 +109,39 @@ const reportDiagnostic = (message: string, error?: unknown): void => { * Internal constructor exported for deterministic in-memory tests. It is not * re-exported from the package entry point. */ -export const createOptionOtelRuntime = ( - options: SetupOptionOtelOptions, +export const createIsolatedOtelRuntime = ( + options: IsolatedOtelTransport, resource: Resource, processors?: { readonly logRecordProcessors?: readonly LogRecordProcessor[]; readonly spanProcessors?: readonly SpanProcessor[]; } -): OptionOtelHandle => { +): IsolatedOtelHandle => { const endpoint = options.endpoint.trim(); let endpointProtocol: string; try { endpointProtocol = new URL(endpoint).protocol; } catch { - throw new TypeError("setupOptionOtel: endpoint must be a valid URL"); + throw new TypeError("createIsolatedOtel: endpoint must be a valid URL"); } if (!(endpointProtocol === "http:" || endpointProtocol === "https:")) { - throw new TypeError("setupOptionOtel: endpoint must use http or https"); + throw new TypeError("createIsolatedOtel: endpoint must use http or https"); + } + const traceparentHeader = options.traceparentHeader; + try { + if (!traceparentHeader) { + throw new TypeError("traceparentHeader is empty"); + } + new Headers().set(traceparentHeader, "validate"); + } catch { + throw new TypeError( + "createIsolatedOtel: traceparentHeader must be a valid HTTP header name" + ); + } + if (traceparentHeader.toLowerCase() === TRACEPARENT_KEY) { + throw new TypeError( + "createIsolatedOtel: traceparentHeader must not be traceparent" + ); } const headers = options.headers ? { ...options.headers } : undefined; const traceEndpoint = resolveOtlpEndpoint("traces", endpoint, {}); @@ -143,17 +171,15 @@ export const createOptionOtelRuntime = ( processors: logRecordProcessors, }); const contextManager = new AsyncLocalStorageContextManager().enable(); - const localSpanKey = createContextKey( - "@photon-ai/otel.option-runtime.local-span" - ); + const localSpanKey = createContextKey("@photon-ai/isolated-otel.local-span"); const traceContextPropagator = new W3CTraceContextPropagator(); - const tracer = tracerProvider.getTracer(DEVELOPER_INSTRUMENTATION_SCOPE); + const tracer = tracerProvider.getTracer(INSTRUMENTATION_SCOPE); let shutdownPromise: Promise | undefined; - const propagation: OptionOtelHandle["propagation"] = { + const propagation: IsolatedOtelHandle["propagation"] = { capture: () => contextManager.active(), extract: (headersObject) => { - const value = headersObject.get(DEVELOPER_TRACEPARENT_HEADER); + const value = headersObject.get(traceparentHeader); if (!value) { return; } @@ -167,16 +193,16 @@ export const createOptionOtelRuntime = ( if (spanContext && trace.isSpanContextValid(spanContext)) { return extracted; } - reportDiagnostic("ignored invalid Developer trace header"); + reportDiagnostic("ignored invalid isolated trace header"); return; } catch (error) { - reportDiagnostic("ignored invalid Developer trace header", error); + reportDiagnostic("ignored invalid isolated trace header", error); return; } }, inject: (headersObject, captured) => { try { - headersObject.delete(DEVELOPER_TRACEPARENT_HEADER); + headersObject.delete(traceparentHeader); const carrier: Record = {}; traceContextPropagator.inject( captured ?? contextManager.active(), @@ -185,16 +211,16 @@ export const createOptionOtelRuntime = ( ); const value = carrier[TRACEPARENT_KEY]; if (value) { - headersObject.set(DEVELOPER_TRACEPARENT_HEADER, value); + headersObject.set(traceparentHeader, value); } } catch (error) { - reportDiagnostic("failed to inject Developer trace header", error); + reportDiagnostic("failed to inject isolated trace header", error); } }, run: (captured, fn) => contextManager.with(captured, fn), }; - const withActiveSpan: OptionOtelHandle["withActiveSpan"] = async ( + const withActiveSpan: IsolatedOtelHandle["withActiveSpan"] = async ( name, options, fn @@ -281,19 +307,15 @@ export const createOptionOtelRuntime = ( return shutdownPromise; }, withActiveSpan, - withSpan: withSpan as OptionOtelHandle["withSpan"], + withSpan: withSpan as IsolatedOtelHandle["withSpan"], }; }; -/** Create an isolated, non-global OTel Runtime using the main Resource. */ -export const setupOptionOtel = ( - options: SetupOptionOtelOptions -): OptionOtelHandle => { - const resource = activeOtelResource(); - if (!resource) { - throw new Error( - "setupOptionOtel: setupOtel() must complete before creating an option runtime" - ); - } - return createOptionOtelRuntime(options, resource); -}; +/** Create an isolated, non-global OTel runtime with its own Resource. */ +export const createIsolatedOtel = ( + options: IsolatedOtelOptions +): IsolatedOtelHandle => + createIsolatedOtelRuntime( + options, + resourceFromAttributes(serviceResourceAttributes(options)) + ); diff --git a/src/service-resource.ts b/src/service-resource.ts new file mode 100644 index 0000000..43a5748 --- /dev/null +++ b/src/service-resource.ts @@ -0,0 +1,36 @@ +import type { Attributes } from "@opentelemetry/api"; + +/** + * Service identity shared by every runtime this package builds. `setupOtel()` + * and `createIsolatedOtel()` both accept these, so a service is named the same + * way regardless of which runtime exports its telemetry. + */ +// biome-ignore assist/source/useSortedInterfaceMembers: required identity precedes optional configuration. +export interface ServiceResourceOptions { + /** Value of the `service.name` Resource attribute. */ + serviceName: string; + /** Value of the `service.version` Resource attribute; omitted when unset. */ + serviceVersion?: string; + /** + * Extra resource attributes attached to every span/log/metric alongside + * `service.name` / `service.version`. An explicit entry here overrides the + * value derived from `serviceName` / `serviceVersion`. + */ + resourceAttributes?: Attributes; +} + +/** + * Build Resource attributes from service identity. `defaults` sit between the + * service keys and `resourceAttributes`, so a caller can always override them. + */ +export const serviceResourceAttributes = ( + options: ServiceResourceOptions, + defaults?: Attributes +): Attributes => ({ + "service.name": options.serviceName, + ...(options.serviceVersion + ? { "service.version": options.serviceVersion } + : {}), + ...defaults, + ...options.resourceAttributes, +}); diff --git a/src/setup.ts b/src/setup.ts index e94d51c..5fb1dcd 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -19,10 +19,7 @@ import { 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 { - type Resource, - resourceFromAttributes, -} from "@opentelemetry/resources"; +import { resourceFromAttributes } from "@opentelemetry/resources"; import { BatchLogRecordProcessor, LoggerProvider as SdkLoggerProvider, @@ -49,8 +46,12 @@ import { } from "./otlp-config"; import { IS_BUN } from "./runtime"; import { clearActiveProviders, setActiveProviders } from "./scope"; +import { + type ServiceResourceOptions, + serviceResourceAttributes, +} from "./service-resource"; -export interface SetupOtelOptions { +export interface SetupOtelOptions extends ServiceResourceOptions { /** * Default OTLP/HTTP base endpoint (e.g. `https://otel.example.com`). The * `/v1/traces`, `/v1/logs`, and `/v1/metrics` paths are appended @@ -96,13 +97,6 @@ export interface SetupOtelOptions { * and auto fetch instrumentation defaults off (see `instrumentFetch`). */ register?: boolean; - /** - * Extra resource attributes attached to every span/log/metric alongside - * `service.name` / `service.version`. - */ - resourceAttributes?: Record; - serviceName: string; - serviceVersion?: string; } export interface OtelHandle { @@ -122,7 +116,6 @@ export interface OtelHandle { } let activeHandle: OtelHandle | undefined; -let activeResource: Resource | undefined; const TRAILING_SLASH = /\/$/; @@ -257,14 +250,11 @@ export function setupOtel(options: SetupOtelOptions): OtelHandle { resolveOtlpHeaders("metrics", options.headers) ); - const resource = resourceFromAttributes({ - "service.name": options.serviceName, - ...(options.serviceVersion - ? { "service.version": options.serviceVersion } - : {}), - "deployment.environment": process.env.DEPLOYMENT_ENV ?? "development", - ...options.resourceAttributes, - }); + const resource = resourceFromAttributes( + serviceResourceAttributes(options, { + "deployment.environment": process.env.DEPLOYMENT_ENV ?? "development", + }) + ); // Context manager + propagator are shared, process-global infrastructure (not // data routing), and the API rejects a duplicate registration — so these are @@ -367,21 +357,14 @@ export function setupOtel(options: SetupOtelOptions): OtelHandle { if (activeHandle === handle) { clearActiveProviders(); activeHandle = undefined; - activeResource = undefined; } }, }; - activeResource = resource; activeHandle = handle; return handle; } -/** Internal bridge used by the instance-level runtime factory. */ -export function activeOtelResource(): Resource | undefined { - return activeResource; -} - /** * Read-only accessor for tests / debug paths that need to know whether * `setupOtel` has already run in this process. diff --git a/src/version.ts b/src/version.ts index 3baf51d..9e7121e 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1,12 @@ -export const PHOTON_OTEL_VERSION = "3.4.0"; +import { version } from "../package.json"; + +/** + * Current package version, reported as the instrumentation scope version on + * tracers and loggers. + * + * Read from `package.json` rather than duplicated as a literal, so it cannot + * drift from the released version: the release pipeline bumps `package.json` + * only, and the bundler inlines this value into `dist/`. The Bun `exports` + * condition resolves the same field straight from source. + */ +export const PHOTON_OTEL_VERSION: string = version; diff --git a/tests/option-runtime.test.ts b/tests/isolated-runtime.test.ts similarity index 66% rename from tests/option-runtime.test.ts rename to tests/isolated-runtime.test.ts index 4671884..75c7e83 100644 --- a/tests/option-runtime.test.ts +++ b/tests/isolated-runtime.test.ts @@ -17,26 +17,53 @@ import { SimpleSpanProcessor, type SpanProcessor, } from "@opentelemetry/sdk-trace-base"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - createOptionOtelRuntime, - setupOptionOtel, -} from "../src/option-runtime"; + createIsolatedOtel, + createIsolatedOtelRuntime, +} from "../src/isolated-runtime"; import { isOtelActive, setupOtel } from "../src/setup"; import { withSpan as withMainSpan } from "../src/with-span"; const ENDPOINT = "http://collector.internal:4318"; -const TRACEPARENT_HEADER = "photon-developer-traceparent"; +const TRACEPARENT_HEADER = "x-test-isolated-traceparent"; const SPAN_ID_PATTERN = /^[0-9a-f]{16}$/u; const TRACE_ID_PATTERN = /^[0-9a-f]{32}$/u; const TRACEPARENT_PATTERN = /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/u; const MAIN_TRACEPARENT = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; +interface PublicExportedSpan { + readonly resource: { + readonly attributes: Readonly>; + }; +} + +const publicExportedSpans = vi.hoisted(() => [] as PublicExportedSpan[]); + +vi.mock("@opentelemetry/exporter-trace-otlp-http", async () => { + const { ExportResultCode } = await import("@opentelemetry/core"); + return { + OTLPTraceExporter: class { + export( + spans: readonly PublicExportedSpan[], + resultCallback: (result: { code: number }) => void + ): void { + publicExportedSpans.push(...spans); + resultCallback({ code: ExportResultCode.SUCCESS }); + } + + shutdown(): Promise { + return Promise.resolve(); + } + }, + }; +}); + const traceparentParts = (headers: Headers): readonly string[] => { const value = headers.get(TRACEPARENT_HEADER); if (!value) { - throw new Error("expected Developer trace header"); + throw new Error("expected isolated trace header"); } return value.split("-"); }; @@ -44,8 +71,8 @@ const traceparentParts = (headers: Headers): readonly string[] => { const createRuntime = (serviceName = "projects-service") => { const spanExporter = new InMemorySpanExporter(); const logExporter = new InMemoryLogRecordExporter(); - const runtime = createOptionOtelRuntime( - { endpoint: ENDPOINT }, + const runtime = createIsolatedOtelRuntime( + { endpoint: ENDPOINT, traceparentHeader: TRACEPARENT_HEADER }, resourceFromAttributes({ "service.name": serviceName }), { logRecordProcessors: [new SimpleLogRecordProcessor(logExporter)], @@ -55,49 +82,77 @@ const createRuntime = (serviceName = "projects-service") => { return { logExporter, runtime, spanExporter }; }; +beforeEach(() => { + publicExportedSpans.length = 0; +}); + afterEach(async () => { if (isOtelActive()) { await setupOtel({ serviceName: "cleanup" }).shutdown(); } }); -describe("setupOptionOtel", () => { - it("requires the main setup without leaving active state", () => { - expect(() => setupOptionOtel({ endpoint: ENDPOINT })).toThrowError( - "setupOtel() must complete" - ); +describe("createIsolatedOtel", () => { + it("starts independently without activating the main runtime", async () => { + const isolated = createIsolatedOtel({ + endpoint: ENDPOINT, + serviceName: "projects-service", + traceparentHeader: TRACEPARENT_HEADER, + }); expect(isOtelActive()).toBe(false); + await isolated.shutdown(); }); it("does not replace or shut down the main runtime", async () => { const main = setupOtel({ serviceName: "main-service" }); - const option = setupOptionOtel({ endpoint: ENDPOINT }); + const isolated = createIsolatedOtel({ + endpoint: ENDPOINT, + serviceName: "projects-service", + traceparentHeader: TRACEPARENT_HEADER, + }); expect(isOtelActive()).toBe(true); - await option.shutdown(); + await isolated.shutdown(); expect(isOtelActive()).toBe(true); expect(setupOtel({ serviceName: "ignored" })).toBe(main); }); - it("keeps the main and option active spans independent", async () => { + it("returns a new independent runtime on every call", async () => { + const first = createIsolatedOtel({ + endpoint: ENDPOINT, + serviceName: "projects-service", + traceparentHeader: TRACEPARENT_HEADER, + }); + const second = createIsolatedOtel({ + endpoint: ENDPOINT, + serviceName: "projects-service", + traceparentHeader: TRACEPARENT_HEADER, + }); + + expect(second).not.toBe(first); + await first.shutdown(); + await second.shutdown(); + }); + + it("keeps the main and isolated active spans independent", async () => { setupOtel({ serviceName: "main-service" }); - const developer = createRuntime(); + const isolated = createRuntime(); await withMainSpan("main", async () => { const mainSpanId = trace.getActiveSpan()?.spanContext().spanId; expect(mainSpanId).toMatch(SPAN_ID_PATTERN); - await developer.runtime.withSpan("developer", () => { + await isolated.runtime.withSpan("isolated", () => { expect(trace.getActiveSpan()?.spanContext().spanId).toBe(mainSpanId); const headers = new Headers(); - developer.runtime.propagation.inject(headers); + isolated.runtime.propagation.inject(headers); expect(traceparentParts(headers)[2]).not.toBe(mainSpanId); }); expect(trace.getActiveSpan()?.spanContext().spanId).toBe(mainSpanId); }); - await developer.runtime.shutdown(); + await isolated.runtime.shutdown(); }); it.each([ @@ -105,13 +160,100 @@ describe("setupOptionOtel", () => { "not-a-url", "ftp://collector.internal", ])("rejects invalid endpoint %j", (endpoint) => { - setupOtel({ serviceName: "main-service" }); - expect(() => setupOptionOtel({ endpoint })).toThrowError(TypeError); + expect(() => + createIsolatedOtel({ + endpoint, + serviceName: "projects-service", + traceparentHeader: TRACEPARENT_HEADER, + }) + ).toThrowError(TypeError); + }); + + it.each([ + "", + "bad header\nname", + "traceparent", + "TraceParent", + ])("rejects invalid traceparent header %j", (traceparentHeader) => { + expect(() => + createIsolatedOtel({ + endpoint: ENDPOINT, + serviceName: "projects-service", + traceparentHeader, + }) + ).toThrowError(TypeError); + }); + + it("exports only the Resource supplied to the independent runtime", async () => { + setupOtel({ + resourceAttributes: { "main.runtime": true }, + serviceName: "main-service", + }); + const isolated = createIsolatedOtel({ + endpoint: ENDPOINT, + serviceName: "isolated-service", + serviceVersion: "1.2.3", + traceparentHeader: TRACEPARENT_HEADER, + }); + + await isolated.withSpan("resource-check", () => undefined); + await isolated.shutdown(); + + expect(publicExportedSpans).toHaveLength(1); + expect(publicExportedSpans[0]?.resource.attributes).toMatchObject({ + "service.name": "isolated-service", + "service.version": "1.2.3", + }); + expect( + publicExportedSpans[0]?.resource.attributes["main.runtime"] + ).toBeUndefined(); + }); + + it("omits service.version and never reads DEPLOYMENT_ENV", async () => { + const previousDeploymentEnv = process.env.DEPLOYMENT_ENV; + process.env.DEPLOYMENT_ENV = "production"; + try { + const isolated = createIsolatedOtel({ + endpoint: ENDPOINT, + serviceName: "isolated-service", + traceparentHeader: TRACEPARENT_HEADER, + }); + + await isolated.withSpan("resource-check", () => undefined); + await isolated.shutdown(); + + const attributes = publicExportedSpans[0]?.resource.attributes; + expect(attributes?.["service.name"]).toBe("isolated-service"); + expect(attributes?.["service.version"]).toBeUndefined(); + expect(attributes?.["deployment.environment"]).toBeUndefined(); + } finally { + if (previousDeploymentEnv === undefined) { + delete process.env.DEPLOYMENT_ENV; + } else { + process.env.DEPLOYMENT_ENV = previousDeploymentEnv; + } + } + }); + + it("lets explicit resourceAttributes override the service identity", async () => { + const isolated = createIsolatedOtel({ + endpoint: ENDPOINT, + resourceAttributes: { "service.name": "explicit-service" }, + serviceName: "isolated-service", + traceparentHeader: TRACEPARENT_HEADER, + }); + + await isolated.withSpan("resource-check", () => undefined); + await isolated.shutdown(); + + expect(publicExportedSpans[0]?.resource.attributes["service.name"]).toBe( + "explicit-service" + ); }); }); -describe("option runtime", () => { - it("keeps nested spans in one isolated Developer trace across await", async () => { +describe("isolated runtime", () => { + it("keeps nested spans in one isolated trace across await", async () => { const { runtime, spanExporter } = createRuntime(); const observedHeaders: string[][] = []; @@ -142,7 +284,7 @@ describe("option runtime", () => { await runtime.shutdown(); }); - it("keeps concurrent requests in separate Developer traces", async () => { + it("keeps concurrent requests in separate traces", async () => { const { runtime } = createRuntime(); const traceIds = await Promise.all( [1, 2].map((value) => @@ -166,7 +308,7 @@ describe("option runtime", () => { let callbackSpanId = ""; await runtime.withActiveSpan( - "developer.http", + "isolated.http", { attributes: { "photon.api_key.id": "pho_sk_test" }, kind: SpanKind.SERVER, @@ -207,21 +349,21 @@ describe("option runtime", () => { it("associates logs with the active local span and inherited Resource", async () => { const { logExporter, runtime } = createRuntime("projects-service"); - const logger = runtime.createLogger("@photon-ai/developer-logs"); + const logger = runtime.createLogger("test.isolated-logger"); - await runtime.withSpan("project.generate", () => { + await runtime.withSpan("report.generate", () => { logger.emit({ - attributes: { "photon.project.id": "pho_prj_123" }, - body: "Starting project generation", - eventName: "developer.message", + attributes: { "app.entity.id": "entity-123" }, + body: "Starting report generation", + eventName: "test.message", severityNumber: SeverityNumber.INFO, severityText: "INFO", }); }); const [record] = logExporter.getFinishedLogRecords(); - expect(record?.body).toBe("Starting project generation"); - expect(record?.instrumentationScope.name).toBe("@photon-ai/developer-logs"); + expect(record?.body).toBe("Starting report generation"); + expect(record?.instrumentationScope.name).toBe("test.isolated-logger"); expect(record?.spanContext?.traceId).toMatch(TRACE_ID_PATTERN); expect(record?.spanContext?.spanId).toMatch(SPAN_ID_PATTERN); expect(record?.resource.attributes["service.name"]).toBe( @@ -253,9 +395,7 @@ describe("option runtime", () => { expect(failure?.events.some((event) => event.name === "exception")).toBe( true ); - expect(failure?.instrumentationScope.name).toBe( - "@photon-ai/developer-logs" - ); + expect(failure?.instrumentationScope.name).toBe("@photon-ai/otel"); await runtime.shutdown(); }); @@ -275,15 +415,15 @@ describe("option runtime", () => { onStart: () => undefined, shutdown: () => Promise.resolve(), } satisfies SpanProcessor; - const runtime = createOptionOtelRuntime( - { endpoint: ENDPOINT }, + const runtime = createIsolatedOtelRuntime( + { endpoint: ENDPOINT, traceparentHeader: TRACEPARENT_HEADER }, resourceFromAttributes({ "service.name": "projects-service" }), { logRecordProcessors: [failingLogProcessor], spanProcessors: [failingSpanProcessor], } ); - const logger = runtime.createLogger("@photon-ai/developer-logs"); + const logger = runtime.createLogger("test.isolated-logger"); const result = { ok: true }; await expect( @@ -303,8 +443,8 @@ describe("option runtime", () => { onStart: () => undefined, shutdown: () => Promise.reject(shutdownError), } satisfies SpanProcessor; - const runtime = createOptionOtelRuntime( - { endpoint: ENDPOINT }, + const runtime = createIsolatedOtelRuntime( + { endpoint: ENDPOINT, traceparentHeader: TRACEPARENT_HEADER }, resourceFromAttributes({ "service.name": "projects-service" }), { spanProcessors: [failingSpanProcessor] } ); @@ -324,7 +464,7 @@ describe("option runtime", () => { upstreamSpanId = parts[2] ?? ""; const extracted = downstream.runtime.propagation.extract(requestHeaders); if (!extracted) { - throw new Error("expected extracted Developer context"); + throw new Error("expected extracted isolated context"); } expect(downstream.runtime.hasActiveSpan()).toBe(false); await downstream.runtime.propagation.run(extracted, async () => { @@ -356,7 +496,7 @@ describe("option runtime", () => { upstreamSpanId = traceparentParts(middleHeaders)[2] ?? ""; const middleContext = middle.runtime.propagation.extract(middleHeaders); if (!middleContext) { - throw new Error("expected middle Developer context"); + throw new Error("expected middle isolated context"); } await middle.runtime.propagation.run(middleContext, async () => { @@ -366,7 +506,7 @@ describe("option runtime", () => { const downstreamContext = downstream.runtime.propagation.extract(downstreamHeaders); if (!downstreamContext) { - throw new Error("expected downstream Developer context"); + throw new Error("expected downstream isolated context"); } await downstream.runtime.propagation.run(downstreamContext, () => downstream.runtime.withSpan("service-c", () => undefined) diff --git a/tests/version.test.ts b/tests/version.test.ts new file mode 100644 index 0000000..80c8aee --- /dev/null +++ b/tests/version.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { version } from "../package.json"; +import { PHOTON_OTEL_VERSION } from "../src/version"; + +describe("PHOTON_OTEL_VERSION", () => { + // Guards against reintroducing a hand-maintained literal, which silently + // drifted from package.json for several releases. CI additionally asserts the + // inlined value in dist/ (see .github/workflows/ci.yml). + it("tracks the package version without a duplicated literal", () => { + expect(PHOTON_OTEL_VERSION).toBe(version); + }); +});