Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
'
57 changes: 26 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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. |
Expand All @@ -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

Expand Down
13 changes: 7 additions & 6 deletions docs/concepts/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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()`
Expand Down Expand Up @@ -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

Expand Down
74 changes: 74 additions & 0 deletions docs/guides/isolated-runtime.mdx
Original file line number Diff line number Diff line change
@@ -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.
75 changes: 0 additions & 75 deletions docs/guides/option-runtime.mdx

This file was deleted.

40 changes: 27 additions & 13 deletions docs/reference/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Import everything from the package root:
```ts
import {
createInstrumentedFetch,
createIsolatedOtel,
createLogger,
getLogLevel,
instrumentFetch,
Expand All @@ -17,7 +18,6 @@ import {
sanitizePhone,
sanitizeUrl,
setLogLevel,
setupOptionOtel,
setupOtel,
withSpan,
} from "@photon-ai/otel";
Expand All @@ -39,7 +39,7 @@ interface SetupOtelOptions {
serviceVersion?: string;
endpoint?: string;
headers?: Record<string, string>;
resourceAttributes?: Record<string, string | number | boolean>;
resourceAttributes?: Attributes;
logLevel?: LogLevel;
instrumentFetch?: boolean | InstrumentFetchOptions;
register?: boolean;
Expand Down Expand Up @@ -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<string, string>;
}
```

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<LogRecord, "context">): void;
};
Expand Down Expand Up @@ -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()`

Expand Down Expand Up @@ -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.
10 changes: 5 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ export {
type InstrumentFetchOptions,
instrumentFetch,
} from "./instrument-fetch";
export {
createIsolatedOtel,
type IsolatedOtelHandle,
type IsolatedOtelOptions,
} from "./isolated-runtime";
Comment thread
underthestars-zhy marked this conversation as resolved.
export {
createLogger,
getLogLevel,
Expand All @@ -14,11 +19,6 @@ export {
type PhotonLogger,
setLogLevel,
} from "./logger";
export {
type OptionOtelHandle,
type SetupOptionOtelOptions,
setupOptionOtel,
} from "./option-runtime";
export {
type SanitizeUrlOptions,
sanitizeEmail,
Expand Down
Loading
Loading