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
3 changes: 2 additions & 1 deletion docs/deviations.md

Large diffs are not rendered by default.

95 changes: 94 additions & 1 deletion docs/sdk-documentation/pipelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,66 @@ All three carry the same `InstrumentationBundle`, so trace and span identity sur
`AsyncLocalStorage`-based, which is why `@dexpace/rx` installs no RxJS scheduler — adding
`observeOn`/`subscribeOn` downstream makes reinstating the context the caller's job.

**What a call leaves behind is nothing.** `send()` scopes both async-scoped stores with
`AsyncLocalStorage.run`, so the moment it settles — resolved or rejected — the active span and the
diagnostic fields are what they were before it. That is a guarantee about *your* context, not only the
pipeline's: an application log emitted after `await client.send(...)` carries no `trace.id` from it.
Inside the call the usual rules apply, and a step that pushes fields with the handle form
(`activateSpanForCorrelation`) should still close its scope in the same continuation that opened it.

## Instrumenting a pipeline

Both constructors take an options bag that the built pipeline carries into every call:

```typescript
import {
PipelineBuilder,
createInstrumentationBundle,
standardResilience,
type PipelineOptions,
type Runtime,
type Tracer,
} from '@dexpace/core';
import {fetchTransport} from '@dexpace/transport-fetch';

declare const otelTracer: Tracer; // whatever your tracing library hands you

const instrumented: PipelineOptions = {
instrumentation: createInstrumentationBundle(() => otelTracer),
operationName: 'GetUser',
};

// `StandardResilienceOptions` extends `PipelineOptions`, so the preset takes the same two fields
// beside its per-pillar ones.
export const preset: Runtime = standardResilience(fetchTransport(), instrumented);
export const handBuilt: Runtime = new PipelineBuilder(
fetchTransport(),
instrumented,
).build();
```

- **`instrumentation`** is the bundle every context of the call carries, and its `tracerFactory` is
what opens spans. `send()` asks it for `'http.client.operation'` once per call and opens **one**
span there (`OBS-29`), outside every pillar — a retry attempt and a redirect hop stay inside it. The
LOGGING pillar asks again per transmission, and those spans are its children. Omitted, the pipeline
carries the no-op bundle (`CTX-15`) and opens no span at all.
- **`operationName`** is `CTX-16`'s advisory label. It is carried unchanged across every promotion,
readable from a custom step as `ctx.context.operationName`, and used to name the tracer the LOGGING
pillar asks for. It never influences the request, the dispatch decision, or the store key.

Both are per-pipeline, not per-call: build a second pipeline for a second operation name.
`PipelineBuilder.seedFrom(runtime, 'flatten')` carries them over, since the flattened builder replaces
the runtime it seeded from; `'nest'` does not need to, because the seeded runtime is still there,
driving its own contexts as the terminal transport.

## The four shipped pillars

| Pillar | Factory | Key settings |
|---|---|---|
| Retry | `retryStep(options?)` | `maxAttempts`, `retryableStatuses`, `totalTimeoutMs`, `attemptHeaderName`, backoff (`initialDelayMs`, `multiplier`, `maxDelayMs`, `jitter`, `fixedDelayMs`), injectable `clock`/`random` |
| Redirect | `redirectStep(overrides?)` | `maxHops`, `allowedMethods`, `allow303`, `allowSchemeDowngrade`, `locationHeader`, `predicate` |
| Auth | `authStep(settings)` | `credentials`, `tiers`, `challengeHook`, `bearerMarginMs` — see [`auth.md`](./auth.md) |
| Logging | `loggingStep(settings?)` | `granularity`, `severity`, `previewSizeBytes`, `droppedHeaderPolicy`, `logger`, `meter`, `tracerFactory` |
| Logging | `loggingStep(settings?)` | `granularity`, `configKey`, `severity`, `previewSizeBytes`, `droppedHeaderPolicy`, `logger`, `meter`, `tracerFactory` |

Retry and redirect are worth a few notes each, because both surprise people:

Expand Down Expand Up @@ -211,6 +263,47 @@ Retry and redirect are worth a few notes each, because both surprise people:
- **A non-replayable body ends a redirect.** `PIPE-40` and `REDIR-22` disagree about what should
happen; this port closes the response and throws (`docs/work/mvp/2026-09-04-open-items-dissolution.md` G1).

### Turning logging on from the environment

`loggingStep()` with no `granularity` resolves one from layered configuration (`OBS-35`), and the
default is `'none'`. What it reads is the process-wide configuration slot — **which starts empty**.
`CFG-13` makes that slot last-write-wins with no ambient default, so nothing in this SDK reads
`process.env` until you say so, and `DEXPACE_LOG_LEVEL=headers` on its own changes nothing:

```typescript
import {
defaultConfiguration,
loggingStep,
setGlobalConfiguration,
standardResilience,
type Runtime,
} from '@dexpace/core';
import {fetchTransport} from '@dexpace/transport-fetch';

// Once, at start-up. `defaultConfiguration()` is the production wiring: its environment seam reads
// the live `process.env` on every lookup, and its property seam finds nothing (Node has no ambient
// key/value store distinct from the environment).
setGlobalConfiguration(defaultConfiguration());

// Now `DEXPACE_LOG_LEVEL=headers` reaches the step. `configKey` renames the variable it reads —
// `OBS-35` says an SDK must not bake a key name in, and `DEXPACE_LOG_LEVEL` is only the default.
export const client: Runtime = standardResilience(fetchTransport(), {
logging: {configKey: 'ACME_HTTP_LOG_LEVEL'},
});
export const step = loggingStep({configKey: 'ACME_HTTP_LOG_LEVEL'});
```

Parsing is tolerant either way: `' Headers '` and `'HEADERS'` both resolve to `'headers'`, and an
absent, empty or unrecognised value falls back to `'none'`. An explicit `granularity` in the settings
wins over both, and is the right choice for a library that does not want to read a host application's
environment at all.

Body-preview capture is best-effort and says so when it fails: a request body that cannot be probed
or a response body that errors mid-drain emits `http.instrumentation.bodyCaptureFailed`, carrying the
direction and the cause, and the request still completes (`OBS-20`). Like every
`http.instrumentation.*` diagnostic it emits at `verbose`, so a logger that filters that level will
not show it.

## Testing a pipeline

Nothing here needs a socket. A `Transport` is two methods, so the test double is a literal:
Expand Down
11 changes: 9 additions & 2 deletions packages/core/etc/core.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,7 @@ export function loggingStep(settings?: LoggingStepSettings): StepDescriptor;
// @public
export interface LoggingStepSettings {
readonly clock?: Clock | undefined;
readonly configKey?: string | undefined;
readonly droppedHeaderPolicy?: DroppedHeaderPolicy | undefined;
readonly granularity?: LoggingGranularity | undefined;
readonly logger?: Logger | undefined;
Expand Down Expand Up @@ -914,7 +915,7 @@ export class PillarCollisionError extends DexpaceError {

// @public
export class PipelineBuilder {
constructor(transport: Transport);
constructor(transport: Transport, options?: PipelineOptions);
append(descriptor: StepDescriptor): this;
appendAll(descriptors: readonly StepDescriptor[]): this;
build(): Runtime;
Expand All @@ -928,6 +929,12 @@ export class PipelineBuilder {
static seedFrom(runtime: Runtime, mode: 'flatten' | 'nest'): PipelineBuilder;
}

// @public
export interface PipelineOptions {
readonly instrumentation?: InstrumentationBundle | undefined;
readonly operationName?: string | undefined;
}

// @public
export class PlaintextCredentialError extends DexpaceError {
constructor(stepName: string, scheme: string);
Expand Down Expand Up @@ -1380,7 +1387,7 @@ export const STAGE_ORDER: readonly Stage[];
export function standardResilience(transport: Transport, options?: StandardResilienceOptions): Runtime;

// @public
export interface StandardResilienceOptions {
export interface StandardResilienceOptions extends PipelineOptions {
readonly auth?: AuthStepSettings | undefined;
readonly logging?: LoggingStepSettings | undefined;
readonly redirect?: Partial<RedirectSettings> | undefined;
Expand Down
72 changes: 71 additions & 1 deletion packages/core/src/auth/preset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@
// Exercises: PIPE-24 ("installs into empty pillar slots only" -- true by construction, since the preset
// always starts from a fresh PipelineBuilder), PIPE-39 (installs exactly the pillars that exist), and
// jointly with 5b: PIPE-2's "auth executes per redirect hop, not once for the whole call" plus
// AUTH-29's marker-CONSUMPTION side (5b produced the marker and routed consumption here).
// AUTH-29's marker-CONSUMPTION side (5b produced the marker and routed consumption here), OBS-29 +
// CTX-16 (the preset forwards an instrumentation bundle and an operation name to the built pipeline).
import {describe, expect, test} from 'bun:test';
import {Request} from '../http/request.js';
import type {Response} from '../http/response.js';
import {
createInstrumentationBundle,
type Span,
type Tracer,
} from '../observability/tracing.js';
import {PipelineBuilder} from '../pipeline/builder.js';
import type {StepDescriptor} from '../pipeline/step.js';
import {CROSS_ORIGIN_MARKER_HEADER} from '../redirect/cross-origin.js';
Expand Down Expand Up @@ -216,3 +222,67 @@ describe('standardResilience logging options (Phase 7b)', () => {
expect(events).toEqual(['http.request', 'http.response']);
});
});

describe('standardResilience instrumentation options (OBS-29, CTX-16)', () => {
test('the supplied bundle opens one operation span per send, and the name reaches the step', async () => {
const spanNames: string[] = [];
const factoryNames: string[] = [];
const span: Span = {
isRecording: true,
setAttribute: (): Span => span,
recordException: (): Span => span,
end: (): void => undefined,
};
const tracer: Tracer = {
startSpan(name: string): Span {
spanNames.push(name);
return span;
},
};
let seen: string | undefined;
const probe: StepDescriptor = {
type: Symbol('operation-name-probe'),
stage: 'PRE_SERDE',
fn: async (request, ctx) => {
seen =
'operationName' in ctx.context
? ctx.context.operationName
: undefined;
return ctx.next(request);
},
};

const runtime = PipelineBuilder.seedFrom(
standardResilience(
new FakeTransport([
countingResponse(200).response,
countingResponse(200).response,
]),
{
instrumentation: createInstrumentationBundle(operationName => {
factoryNames.push(operationName);
return tracer;
}),
operationName: 'GetUser',
},
),
'flatten',
)
.append(probe)
.build();

await runtime.send(aRequest());
await runtime.send(aRequest());

// `Runtime.send()` asks the factory for the operation's own tracer once per call (OBS-29's 1:1
// binding), and the LOGGING pillar asks for one labelled with CTX-16's operation name per attempt.
expect(
factoryNames.filter(name => name === 'http.client.operation'),
).toHaveLength(2);
expect(factoryNames.filter(name => name === 'GetUser')).toHaveLength(2);
expect(
spanNames.filter(name => name === 'http.client.operation'),
).toHaveLength(2);
expect(seen).toBe('GetUser');
});
});
17 changes: 14 additions & 3 deletions packages/core/src/auth/preset.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: MIT
// packages/core/src/auth/preset.ts
import {PipelineBuilder} from '../pipeline/builder.js';
import {PipelineBuilder, type PipelineOptions} from '../pipeline/builder.js';
import type {Runtime} from '../pipeline/runtime.js';
import type {RedirectSettings} from '../redirect/settings.js';
import {withRedirect} from '../redirect/strip-marker-step.js';
Expand All @@ -20,7 +20,7 @@ import {
*
* @public
*/
export interface StandardResilienceOptions {
export interface StandardResilienceOptions extends PipelineOptions {
/** Retry settings and injected seams; omitted yields 5a's spec defaults. */
readonly retry?: RetryStepOptions | undefined;
/** Redirect policy overrides; omitted yields 5b's spec defaults. */
Expand Down Expand Up @@ -62,6 +62,12 @@ function noAuthSettings(): AuthStepSettings {
* `LOGGING` installs {@link loggingStep} to emit telemetry and metrics around dispatches. `SERDE` remains
* reserved with no shipped behavior anywhere in this roadmap's current scope.
*
* The two inherited {@link PipelineOptions} fields are pipeline-wide rather than per-pillar:
* `instrumentation` is the bundle every context of every call carries, and the source of the one
* `http.client.operation` span each `send()` opens (`OBS-29`); `operationName` is `CTX-16`'s advisory
* label. Omitting `instrumentation` leaves the no-op bundle in place, which opens no span at all — it
* is the only way to switch tracing on for a preset-built pipeline.
*
* This function only assembles the pipeline. The failures below surface from the returned runtime's
* `send()`, and are documented here because this factory is where a caller chooses the auth
* configuration that determines whether they can occur at all.
Expand Down Expand Up @@ -104,7 +110,12 @@ export function standardResilience(
transport: Transport,
options: StandardResilienceOptions = {},
): Runtime {
const builder = new PipelineBuilder(transport);
// The two `PipelineOptions` fields are pipeline-wide rather than per-pillar, so they go to the
// builder rather than into a step's settings; everything below installs one pillar each.
const builder = new PipelineBuilder(transport, {
instrumentation: options.instrumentation,
operationName: options.operationName,
});
return withRedirect(builder, options.redirect)
.append(retryStep(options.retry))
.append(authStep(options.auth ?? noAuthSettings()))
Expand Down
23 changes: 18 additions & 5 deletions packages/core/src/context/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import {NOOP_SPAN} from '../observability/span.js';
* `activeSpan` and `tracerFactory` are `unknown` rather than a `Span`/`Tracer`. The original reason —
* "nothing in the package consumes either yet, pending Phase 7a" — expired when Phase 7a landed
* (`bd37a08`) and shipped `Span` and `Tracer` in `observability/tracing.ts`. That phase did not narrow
* these two: `tracerFactory` is consumed, by `pipeline/runtime.ts:52` and
* `observability/logging-step.ts:263`, both of which reach a `Tracer` through a cast;
* `createInstrumentationBundle` fills `activeSpan` (`observability/tracing.ts:222`), and nothing in
* these two: `tracerFactory` is consumed, by `pipeline/runtime.ts:60-65` and
* `observability/logging-step.ts:305-312`, both of which reach a `Tracer` through a cast;
* `createInstrumentationBundle` fills `activeSpan` (`observability/tracing.ts:191`), and nothing in
* this package reads it back.
*
* They stay `unknown` because narrowing a published member is a breaking change rather than a
Expand Down Expand Up @@ -46,11 +46,24 @@ export interface InstrumentationBundle {
/**
* Returns the tracer to open `operationName`'s span from — a **tracer**, not a started span. Every
* consumer in this package narrows the result and calls `startSpan()` on it itself
* (`packages/core/src/pipeline/runtime.ts:52-57`, `observability/logging-step.ts:263-269`), and
* (`packages/core/src/pipeline/runtime.ts:60-65`, `observability/logging-step.ts:305-312`), and
* `createInstrumentationBundle` supplies a `(operationName: string) => Tracer`
* (`observability/tracing.ts:212,223`). A no-op returning `undefined` when tracing is disabled;
* (`observability/tracing.ts:180,191`). A no-op returning `undefined` when tracing is disabled;
* both consumers substitute `NOOP_TRACER` for that `undefined`.
*
* **It is asked twice per call, for two different scopes.** `Runtime.send()` asks for
* `'http.client.operation'`'s tracer once per call and opens `OBS-29`'s one-per-operation span from
* it, outside every pillar, so a retry attempt and a redirect hop stay inside it. The LOGGING pillar
* step asks again per transmission, under `CTX-16`'s operation name when the pipeline was built with
* one and `'http.client.request'` otherwise, and its spans are children of the first. Returning one
* shared tracer for both is fine; `OBS-29`'s 1:1 clause is about the operation *span*, which
* `send()` opens exactly once whatever this returns.
*
* A consumer reaches this by building a bundle with `createInstrumentationBundle(tracerFactory)` and
* passing it as `PipelineOptions.instrumentation` — to `new PipelineBuilder(transport, options)` or
* to `standardResilience(transport, options)`. Before 2026-09-05 there was no public route: every
* pipeline a consumer could build carried the no-op bundle (audit #67 / #80).
*
* PROVISIONAL: the return type is `unknown`, and narrowing it is a version-bump decision — see this
* interface's own note for why Phase 7a landing did not settle it.
*
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ export type {
RequestContext,
} from './context/context.js';
export type {InstrumentationBundle} from './context/instrumentation.js';
// `PipelineOptions` is what a caller passes to `new PipelineBuilder(transport, options)` and, by
// extension, to `standardResilience`; it is the only public route to `OBS-29`'s per-operation span
// and `CTX-16`'s operation name, so it is exported beside the bundle it carries.
export type {PipelineOptions} from './pipeline/builder.js';
export type {BackoffSettings} from './retry/backoff.js';
export type {RetrySettings} from './retry/settings.js';
export type {RetryStepOptions} from './retry/retry-step.js';
Expand Down
36 changes: 35 additions & 1 deletion packages/core/src/observability/diagnostic-context.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: MIT
// packages/core/src/observability/diagnostic-context.test.ts
// Exercises: OBS-10 (default allow-list {trace.id, span.id}, null allow-list folds all, null values skipped),
// OBS-24 (immutable snapshot bridge: capture, reinstall, restore including on throw).
// OBS-24 (immutable snapshot bridge: capture, reinstall, restore including on throw), OBS-22/OBS-23's
// scoping mechanics (AsyncScopedStore.run unwinds across an await and over an enter() its callback made,
// where the enter() handle alone does not).
import {describe, expect, test} from 'bun:test';
import {
captureDiagnosticSnapshot,
Expand Down Expand Up @@ -156,6 +158,38 @@ describe('pushDiagnosticFields', () => {
});

describe('createAsyncScopedStore', () => {
test('run installs the value for the callback and restores across an await', async () => {
const store = createAsyncScopedStore<string>();
expect(store.get()).toBeUndefined();

await store.run('scoped', async () => {
expect(store.get()).toBe('scoped');
await Promise.resolve();
expect(store.get()).toBe('scoped');
});

expect(store.get()).toBeUndefined();
});

test('run unwinds an enter() its callback left open, and restores on a throw', () => {
const store = createAsyncScopedStore<string>();
store.run('outer', () => {
// The handle form, deliberately never closed -- what the LOGGING pillar's correlation scope
// effectively does once its close() lands in a later continuation.
store.enter('inner');
expect(store.get()).toBe('inner');
});
expect(store.get()).toBeUndefined();

expect(() => {
store.run('outer', () => {
store.enter('inner');
throw new Error('boom');
});
}).toThrow('boom');
expect(store.get()).toBeUndefined();
});

test('enter installs value and returned restore function resets prior value', () => {
const store = createAsyncScopedStore<string>();
expect(store.get()).toBeUndefined();
Expand Down
Loading
Loading