@diveinto/obs 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/dist/api.d.ts +38 -0
- package/dist/api.js +80 -0
- package/dist/config.d.ts +83 -0
- package/dist/config.js +60 -0
- package/dist/context.d.ts +88 -0
- package/dist/context.js +193 -0
- package/dist/http.d.ts +49 -0
- package/dist/http.js +185 -0
- package/dist/index.d.ts +79 -0
- package/dist/index.js +82 -0
- package/dist/metrics.d.ts +26 -0
- package/dist/metrics.js +111 -0
- package/dist/otel-start.d.ts +6 -0
- package/dist/otel-start.js +106 -0
- package/dist/otel.d.ts +26 -0
- package/dist/otel.js +173 -0
- package/dist/record.d.ts +29 -0
- package/dist/record.js +93 -0
- package/dist/render.d.ts +39 -0
- package/dist/render.js +131 -0
- package/dist/setup.d.ts +21 -0
- package/dist/setup.js +150 -0
- package/dist/sinks.d.ts +46 -0
- package/dist/sinks.js +243 -0
- package/dist/types.d.ts +66 -0
- package/dist/types.js +19 -0
- package/package.json +86 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 James Spurin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# @diveinto/obs
|
|
2
|
+
|
|
3
|
+
Structured logging, tracing and metrics for Next.js and Node services. One
|
|
4
|
+
record shape, written to stdout and to two rotating files, with W3C trace
|
|
5
|
+
context threaded through it, and optional OpenTelemetry export that stays off
|
|
6
|
+
until you configure an endpoint.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @diveinto/obs
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Why it exists
|
|
13
|
+
|
|
14
|
+
It grew out of consolidating four hand-maintained copies of the same library
|
|
15
|
+
across four services. Copies drift: by the time they were merged, one had
|
|
16
|
+
real OpenTelemetry spans around requests and the others did not, one had
|
|
17
|
+
route templating that kept metric cardinality bounded, one had fixed a
|
|
18
|
+
duration that went unrecorded when something threw a non-`Error`, and one had
|
|
19
|
+
made log-file resolution a pure, testable function. This package is the union
|
|
20
|
+
of the four, and each of those behaviours is pinned by a test.
|
|
21
|
+
|
|
22
|
+
## What you get
|
|
23
|
+
|
|
24
|
+
- **One record shape**, frozen, identical in the text and JSON renderers:
|
|
25
|
+
`ts level trace_id span_id parent_span_id logger event msg` plus context.
|
|
26
|
+
A sibling Python implementation writes the same shape, so one `grep` can
|
|
27
|
+
follow a trace across services written in either language.
|
|
28
|
+
- **Three sinks**: stdout (always), and two rotating files that are
|
|
29
|
+
format-locked, so `<stem>.log` is always text and `<stem>.jsonl` is always
|
|
30
|
+
JSON regardless of `LOG_FORMAT`. Daily rotation, 14 kept, both configurable.
|
|
31
|
+
- **Trace context** on `AsyncLocalStorage`, continuing an inbound W3C
|
|
32
|
+
`traceparent` and propagating it outbound, so one `trace_id` spans every
|
|
33
|
+
hop.
|
|
34
|
+
- **Optional OTLP export** to any OpenTelemetry collector. Off unless
|
|
35
|
+
`OTEL_EXPORTER_OTLP_ENDPOINT` is set, and the first-party logs never depend
|
|
36
|
+
on it.
|
|
37
|
+
- **Serverless-aware**: on Vercel or Lambda the file sinks switch themselves
|
|
38
|
+
off rather than failing, because there is no persistent writable disk.
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
Once, from the app's `instrumentation.ts`, on the Node runtime:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
export async function register() {
|
|
46
|
+
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
|
47
|
+
|
|
48
|
+
// Optional: start OTLP export first, so the earliest logs adopt OTel ids.
|
|
49
|
+
const { startOtel } = await import("@diveinto/obs/start");
|
|
50
|
+
await startOtel("my-service");
|
|
51
|
+
|
|
52
|
+
const { createObs } = await import("@diveinto/obs");
|
|
53
|
+
createObs({ service: "my-service", envPrefix: "MYSVC" });
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Everywhere else, plain named imports:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { getLogger, withApiTrace, withAction, traceHeaders } from "@diveinto/obs";
|
|
61
|
+
|
|
62
|
+
const log = getLogger("billing.invoice");
|
|
63
|
+
|
|
64
|
+
export const GET = (req: Request) =>
|
|
65
|
+
withApiTrace(req, async () => {
|
|
66
|
+
log.info("invoice.fetch", "fetching invoice", { invoice_id: id });
|
|
67
|
+
const res = await fetch(url, { headers: traceHeaders() }); // same trace, next hop
|
|
68
|
+
return Response.json(await res.json());
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`withApiTrace` opens or continues the trace, logs `request.received` and
|
|
73
|
+
`request.completed`, records a duration histogram, and sets `X-Trace-Id` on
|
|
74
|
+
the response. `withAction` does the same for a server action in a fresh root
|
|
75
|
+
trace.
|
|
76
|
+
|
|
77
|
+
## Configuration
|
|
78
|
+
|
|
79
|
+
| field | required | what it does |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `service` | yes | The OpenTelemetry tracer and meter name, the `service.name` fallback, and the default var dir and file stem |
|
|
82
|
+
| `envPrefix` | yes | The prefix on this service's own env vars. `MYSVC` reads `MYSVC_LOG_TEXT_FILE`, `MYSVC_VAR_DIR` and the rest, so a service adopting the library keeps the env file it already has |
|
|
83
|
+
| `defaultVarDir` | no | State dir when `<PREFIX>_VAR_DIR` is unset. Default `/var/lib/<service>` |
|
|
84
|
+
| `fileStem` | no | Stem for the two log files. Default: the last dash-separated word of `service` |
|
|
85
|
+
| `routeTemplate` | no | Collapse dynamic path segments before a path becomes a metric label: `/orders/abc123` to `/orders/{id}`. Without it every id is its own time series |
|
|
86
|
+
| `traceSeed` | no | Where a request's trace continues from when there is no W3C `traceparent`, for a front end that passes a correlation id of its own |
|
|
87
|
+
| `quietPathPrefixes` | no | Request paths whose completion logs at debug rather than info. Health checks and internal polling otherwise bury what matters |
|
|
88
|
+
|
|
89
|
+
### Environment
|
|
90
|
+
|
|
91
|
+
Generic, never prefixed: `LOG_LEVEL` (`trace`/`debug`/`info`/`warn`/`error`/
|
|
92
|
+
`fatal`), `LOG_FORMAT` (`pretty` or `json`, stdout only), and the standard
|
|
93
|
+
`OTEL_*` set.
|
|
94
|
+
|
|
95
|
+
Per service, prefixed: `<PREFIX>_VAR_DIR`, `<PREFIX>_LOG_TEXT_FILE`,
|
|
96
|
+
`_TEXT_ROTATION`, `_TEXT_RETENTION`, and the same three for `_JSON_`. A
|
|
97
|
+
`*_FILE` value is a path, or `1` for the default path, or `0`/`off`.
|
|
98
|
+
|
|
99
|
+
## Cardinality
|
|
100
|
+
|
|
101
|
+
Metric attributes become a separate time series per distinct value, so they
|
|
102
|
+
must be low cardinality: an outcome, a status class, a route **template**.
|
|
103
|
+
Never a user id, order id or request id. Those belong on spans and logs,
|
|
104
|
+
where the point is finding the one bad request, and are toxic on metrics.
|
|
105
|
+
`routeTemplate` exists for exactly this reason.
|
|
106
|
+
|
|
107
|
+
The library registers no metric reader, so metrics are no-ops unless you wire
|
|
108
|
+
one deliberately. That is a scar: auto-exporting metrics alongside traces
|
|
109
|
+
once produced tens of millions of samples a month that nobody read.
|
|
110
|
+
|
|
111
|
+
## Requirements
|
|
112
|
+
|
|
113
|
+
Node 22 or newer, ESM. `@opentelemetry/api` is a peer dependency; the SDK
|
|
114
|
+
packages are optional peers, needed only if you use OTLP export.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Fields, LevelInput } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Emit one named story beat.
|
|
4
|
+
*
|
|
5
|
+
* `event` is the machine-stable key (e.g. "lab.launch.started"); `message` is
|
|
6
|
+
* the human sentence; `fields` carry the salient facts (ids, counts, durations,
|
|
7
|
+
* the actual target). Use it at meaningful seams.
|
|
8
|
+
*/
|
|
9
|
+
export declare function logEvent(event: string, message?: string, fields?: Fields, opts?: {
|
|
10
|
+
level?: LevelInput;
|
|
11
|
+
logger?: string;
|
|
12
|
+
error?: unknown;
|
|
13
|
+
}): void;
|
|
14
|
+
type SpanOpts = {
|
|
15
|
+
fields?: Fields;
|
|
16
|
+
logger?: string;
|
|
17
|
+
level?: LevelInput;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Open a child span around a unit of work: mints a child span id, binds
|
|
21
|
+
* `fields` as baggage (so they appear on every line inside), times the body,
|
|
22
|
+
* and logs `<name>.start` / `<name>.finish` (with dur_ms). On a throw it logs
|
|
23
|
+
* `<name>.error` with the stack and re-raises. The parent context is restored
|
|
24
|
+
* automatically when the body settles.
|
|
25
|
+
*/
|
|
26
|
+
export declare function withSpan<T>(name: string, fn: () => Promise<T> | T, opts?: SpanOpts): Promise<T>;
|
|
27
|
+
/**
|
|
28
|
+
* Like withSpan, but opens a fresh ROOT trace (for background work with no
|
|
29
|
+
* inbound request: a scheduler tick, a server action). `seed` continues an
|
|
30
|
+
* inbound trace when its ids are provided.
|
|
31
|
+
*/
|
|
32
|
+
export declare function withTrace<T>(name: string, fn: () => Promise<T> | T, opts?: SpanOpts & {
|
|
33
|
+
seed?: {
|
|
34
|
+
traceId?: string;
|
|
35
|
+
parentSpanId?: string;
|
|
36
|
+
};
|
|
37
|
+
}): Promise<T>;
|
|
38
|
+
export { bind } from "./context.js";
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ergonomic surface feature code reaches for: logEvent, span/withSpan,
|
|
3
|
+
* withTrace, plus the bind/current re-exports.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors the agent's `obs/api.py`. The guiding rule is the same one that kept
|
|
6
|
+
* the agent framework small and is the explicit lesson from the abandoned OTel
|
|
7
|
+
* attempt: instrument the few meaningful seams and decisions, NOT every line. A
|
|
8
|
+
* handful of events/spans per flow, never hundreds. If you are tempted to add a
|
|
9
|
+
* fifth logEvent to one function, you probably want a span instead.
|
|
10
|
+
*/
|
|
11
|
+
import { bind, runWithSpan, runWithTrace } from "./context.js";
|
|
12
|
+
import { otelEnabled, withOtelSpan } from "./otel.js";
|
|
13
|
+
import { emit, normalizeLevel } from "./record.js";
|
|
14
|
+
/** The default logger name for one-off events not tied to a module logger. */
|
|
15
|
+
const DEFAULT_LOGGER = "event";
|
|
16
|
+
/**
|
|
17
|
+
* Emit one named story beat.
|
|
18
|
+
*
|
|
19
|
+
* `event` is the machine-stable key (e.g. "lab.launch.started"); `message` is
|
|
20
|
+
* the human sentence; `fields` carry the salient facts (ids, counts, durations,
|
|
21
|
+
* the actual target). Use it at meaningful seams.
|
|
22
|
+
*/
|
|
23
|
+
export function logEvent(event, message, fields, opts) {
|
|
24
|
+
emit(normalizeLevel(opts?.level ?? "INFO"), opts?.logger ?? DEFAULT_LOGGER, event, message ?? "", fields, opts?.error);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Open a child span around a unit of work: mints a child span id, binds
|
|
28
|
+
* `fields` as baggage (so they appear on every line inside), times the body,
|
|
29
|
+
* and logs `<name>.start` / `<name>.finish` (with dur_ms). On a throw it logs
|
|
30
|
+
* `<name>.error` with the stack and re-raises. The parent context is restored
|
|
31
|
+
* automatically when the body settles.
|
|
32
|
+
*/
|
|
33
|
+
export async function withSpan(name, fn, opts) {
|
|
34
|
+
const logger = opts?.logger ?? DEFAULT_LOGGER;
|
|
35
|
+
const level = normalizeLevel(opts?.level ?? "INFO");
|
|
36
|
+
const inner = () => runWithSpan(async () => {
|
|
37
|
+
if (opts?.fields)
|
|
38
|
+
bind(opts.fields);
|
|
39
|
+
const start = Date.now();
|
|
40
|
+
emit(level, logger, `${name}.start`, name, opts?.fields);
|
|
41
|
+
try {
|
|
42
|
+
const out = await fn();
|
|
43
|
+
emit(level, logger, `${name}.finish`, name, { dur_ms: Date.now() - start });
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
emit("ERROR", logger, `${name}.error`, `${name} failed: ${err instanceof Error ? err.message : String(err)}`, { dur_ms: Date.now() - start }, err);
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
// Open a real OTel child span when export is on, so the domain story appears
|
|
52
|
+
// in the backend; the logs inside adopt the OTel span ids via context.current.
|
|
53
|
+
return otelEnabled() ? withOtelSpan(name, opts?.fields ?? {}, inner) : inner();
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Like withSpan, but opens a fresh ROOT trace (for background work with no
|
|
57
|
+
* inbound request: a scheduler tick, a server action). `seed` continues an
|
|
58
|
+
* inbound trace when its ids are provided.
|
|
59
|
+
*/
|
|
60
|
+
export async function withTrace(name, fn, opts) {
|
|
61
|
+
const logger = opts?.logger ?? DEFAULT_LOGGER;
|
|
62
|
+
const level = normalizeLevel(opts?.level ?? "INFO");
|
|
63
|
+
const inner = () => runWithTrace(opts?.seed ?? {}, async () => {
|
|
64
|
+
if (opts?.fields)
|
|
65
|
+
bind(opts.fields);
|
|
66
|
+
const start = Date.now();
|
|
67
|
+
emit(level, logger, `${name}.start`, name, opts?.fields);
|
|
68
|
+
try {
|
|
69
|
+
const out = await fn();
|
|
70
|
+
emit(level, logger, `${name}.finish`, name, { dur_ms: Date.now() - start });
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
emit("ERROR", logger, `${name}.error`, `${name} failed: ${err instanceof Error ? err.message : String(err)}`, { dur_ms: Date.now() - start }, err);
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return otelEnabled() ? withOtelSpan(name, opts?.fields ?? {}, inner) : inner();
|
|
79
|
+
}
|
|
80
|
+
export { bind } from "./context.js";
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a service tells the library about itself, and the module-level home
|
|
3
|
+
* for it.
|
|
4
|
+
*
|
|
5
|
+
* Everything else in this package is generic. These are the only knobs, and
|
|
6
|
+
* they exist for one reason: four services already run this code with four
|
|
7
|
+
* env prefixes and four sets of live env files. `envPrefix` means none of
|
|
8
|
+
* those files change when a service adopts the package.
|
|
9
|
+
*
|
|
10
|
+
* `configure()` is called once, from the app's `instrumentation.ts`, before
|
|
11
|
+
* anything logs. It is idempotent by design rather than by accident: Next
|
|
12
|
+
* calls `register()` more than once across HMR and can isolate the
|
|
13
|
+
* instrumentation hook from route handlers, so a second call with the same
|
|
14
|
+
* service name is a no-op and a call with a DIFFERENT one is a mistake worth
|
|
15
|
+
* hearing about.
|
|
16
|
+
*/
|
|
17
|
+
/** How a path becomes a metric label. See `routeTemplate` below. */
|
|
18
|
+
export type RouteTemplate = (path: string) => string;
|
|
19
|
+
/** Where a request's trace should continue from. See `traceSeed` below. */
|
|
20
|
+
export type TraceSeedFn = (req: Request) => {
|
|
21
|
+
traceId?: string;
|
|
22
|
+
parentSpanId?: string;
|
|
23
|
+
};
|
|
24
|
+
export type ObsConfig = {
|
|
25
|
+
/**
|
|
26
|
+
* The service's own name: the OTel tracer and meter name, the fallback for
|
|
27
|
+
* `service.name`, and the stem of the two log files. Use the unit name,
|
|
28
|
+
* e.g. "my-service".
|
|
29
|
+
*/
|
|
30
|
+
service: string;
|
|
31
|
+
/**
|
|
32
|
+
* The prefix on this service's own env vars: "MYSVC" reads
|
|
33
|
+
* MYSVC_LOG_TEXT_FILE, MYSVC_VAR_DIR and the rest. The generic names
|
|
34
|
+
* (LOG_LEVEL, LOG_FORMAT, OTEL_*) are never prefixed. This is the whole
|
|
35
|
+
* compatibility story: a service adopting the library keeps the env file
|
|
36
|
+
* it already has, unchanged.
|
|
37
|
+
*/
|
|
38
|
+
envPrefix: string;
|
|
39
|
+
/** State dir when <PREFIX>_VAR_DIR is unset. Default /var/lib/<service>. */
|
|
40
|
+
defaultVarDir?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Stem for the two log files under <var dir>/logs, when the env does not
|
|
43
|
+
* name a path. Defaults to the last dash-separated word of `service`, so
|
|
44
|
+
* "acme-web-api" writes api.log and api.jsonl.
|
|
45
|
+
*/
|
|
46
|
+
fileStem?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Collapse dynamic path segments before a path becomes a metric label:
|
|
49
|
+
* `/api/labs/abc123` to `/api/labs/{identifier}`. Without it every id is its
|
|
50
|
+
* own time series, which is how a metrics backend falls over. The patterns
|
|
51
|
+
* are the service's own routes, so the service supplies them. Omitted means
|
|
52
|
+
* the raw path, which is fine for a service with few dynamic routes.
|
|
53
|
+
*/
|
|
54
|
+
routeTemplate?: RouteTemplate;
|
|
55
|
+
/**
|
|
56
|
+
* Where an inbound request's trace comes from, when it is not a W3C
|
|
57
|
+
* `traceparent`. Some front ends pass a correlation UUID of their own as a
|
|
58
|
+
* header or `?trace=`; accepting it makes a user's journey one trace rather
|
|
59
|
+
* than two unconnected halves. Omitted means `traceparent` only.
|
|
60
|
+
*/
|
|
61
|
+
traceSeed?: TraceSeedFn;
|
|
62
|
+
/**
|
|
63
|
+
* Request paths logged at debug rather than info. Health checks and
|
|
64
|
+
* internal polling otherwise bury the requests a person cares about.
|
|
65
|
+
* Matched by prefix.
|
|
66
|
+
*/
|
|
67
|
+
quietPathPrefixes?: string[];
|
|
68
|
+
};
|
|
69
|
+
type Resolved = Required<Pick<ObsConfig, "service" | "envPrefix" | "defaultVarDir" | "fileStem">> & Pick<ObsConfig, "routeTemplate" | "traceSeed"> & {
|
|
70
|
+
quietPathPrefixes: string[];
|
|
71
|
+
};
|
|
72
|
+
export declare function configure(cfg: ObsConfig): void;
|
|
73
|
+
/**
|
|
74
|
+
* The live config. Falling back rather than throwing is deliberate: a log
|
|
75
|
+
* line emitted before `configure()` (an import-time warning, say) should
|
|
76
|
+
* still come out, named honestly, instead of taking the process down.
|
|
77
|
+
*/
|
|
78
|
+
export declare function obsConfig(): Resolved;
|
|
79
|
+
/** Test seam: forget the configuration so a test can set a different one. */
|
|
80
|
+
export declare function resetConfigForTests(): void;
|
|
81
|
+
/** Read one of this service's own prefixed variables. */
|
|
82
|
+
export declare function envVar(name: string, env?: NodeJS.ProcessEnv): string | undefined;
|
|
83
|
+
export {};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a service tells the library about itself, and the module-level home
|
|
3
|
+
* for it.
|
|
4
|
+
*
|
|
5
|
+
* Everything else in this package is generic. These are the only knobs, and
|
|
6
|
+
* they exist for one reason: four services already run this code with four
|
|
7
|
+
* env prefixes and four sets of live env files. `envPrefix` means none of
|
|
8
|
+
* those files change when a service adopts the package.
|
|
9
|
+
*
|
|
10
|
+
* `configure()` is called once, from the app's `instrumentation.ts`, before
|
|
11
|
+
* anything logs. It is idempotent by design rather than by accident: Next
|
|
12
|
+
* calls `register()` more than once across HMR and can isolate the
|
|
13
|
+
* instrumentation hook from route handlers, so a second call with the same
|
|
14
|
+
* service name is a no-op and a call with a DIFFERENT one is a mistake worth
|
|
15
|
+
* hearing about.
|
|
16
|
+
*/
|
|
17
|
+
let current = null;
|
|
18
|
+
function resolve(cfg) {
|
|
19
|
+
const service = cfg.service.trim();
|
|
20
|
+
if (!service)
|
|
21
|
+
throw new Error("[obs] configure() needs a service name");
|
|
22
|
+
const prefix = cfg.envPrefix.trim().toUpperCase();
|
|
23
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(prefix)) {
|
|
24
|
+
throw new Error(`[obs] envPrefix '${cfg.envPrefix}' is not a usable env var prefix`);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
service,
|
|
28
|
+
envPrefix: prefix,
|
|
29
|
+
defaultVarDir: cfg.defaultVarDir ?? `/var/lib/${service}`,
|
|
30
|
+
fileStem: cfg.fileStem ?? (service.split("-").pop() || service),
|
|
31
|
+
routeTemplate: cfg.routeTemplate,
|
|
32
|
+
traceSeed: cfg.traceSeed,
|
|
33
|
+
quietPathPrefixes: cfg.quietPathPrefixes ?? [],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function configure(cfg) {
|
|
37
|
+
const next = resolve(cfg);
|
|
38
|
+
if (current && current.service !== next.service) {
|
|
39
|
+
// Two services in one process is not a thing this library supports, and
|
|
40
|
+
// the symptom (logs filed under the wrong service) is miserable to chase.
|
|
41
|
+
throw new Error(`[obs] already configured as '${current.service}'; cannot reconfigure as '${next.service}'`);
|
|
42
|
+
}
|
|
43
|
+
current = next;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The live config. Falling back rather than throwing is deliberate: a log
|
|
47
|
+
* line emitted before `configure()` (an import-time warning, say) should
|
|
48
|
+
* still come out, named honestly, instead of taking the process down.
|
|
49
|
+
*/
|
|
50
|
+
export function obsConfig() {
|
|
51
|
+
return current ?? (current = resolve({ service: "unconfigured", envPrefix: "OBS" }));
|
|
52
|
+
}
|
|
53
|
+
/** Test seam: forget the configuration so a test can set a different one. */
|
|
54
|
+
export function resetConfigForTests() {
|
|
55
|
+
current = null;
|
|
56
|
+
}
|
|
57
|
+
/** Read one of this service's own prefixed variables. */
|
|
58
|
+
export function envVar(name, env = process.env) {
|
|
59
|
+
return env[`${obsConfig().envPrefix}_${name}`];
|
|
60
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Fields } from "./types.js";
|
|
2
|
+
/** The ambient trace state for the current async scope. */
|
|
3
|
+
export type TraceState = {
|
|
4
|
+
/** 32 hex chars, or "" when there is no active trace. */
|
|
5
|
+
traceId: string;
|
|
6
|
+
/** 16 hex chars, or "". */
|
|
7
|
+
spanId: string;
|
|
8
|
+
/** 16 hex chars, or "" for a root span. */
|
|
9
|
+
parentSpanId: string;
|
|
10
|
+
/** W3C sampled flag; we always log locally, this only guides a future exporter. */
|
|
11
|
+
sampled: boolean;
|
|
12
|
+
/** Domain attributes stamped onto every line in scope (lab/session/agent ids). */
|
|
13
|
+
baggage: Fields;
|
|
14
|
+
};
|
|
15
|
+
/** Mint a fresh 128-bit trace id as 32 lowercase hex chars (W3C format). */
|
|
16
|
+
export declare function newTraceId(): string;
|
|
17
|
+
/** Mint a fresh 64-bit span id as 16 lowercase hex chars (W3C format). */
|
|
18
|
+
export declare function newSpanId(): string;
|
|
19
|
+
/** The current trace state. When OTLP export is live, the active OTel span is
|
|
20
|
+
* authoritative for the ids (so logs match the backend exactly and traceHeaders
|
|
21
|
+
* injects the OTel trace); baggage stays in our AsyncLocalStorage store.
|
|
22
|
+
* Outside any trace, the empty stand-in. */
|
|
23
|
+
export declare function current(): TraceState;
|
|
24
|
+
export declare function currentTraceId(): string;
|
|
25
|
+
export declare function currentSpanId(): string;
|
|
26
|
+
export declare function currentBaggage(): Fields;
|
|
27
|
+
export declare function isSampled(): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Run `fn` inside a brand-new ROOT trace.
|
|
30
|
+
*
|
|
31
|
+
* `seed.traceId` continues an inbound trace (e.g. a `traceparent` from the
|
|
32
|
+
* upstream caller); when absent a fresh id is minted. `seed.parentSpanId` is the
|
|
33
|
+
* caller's span id from the inbound header, so our first span points back at the
|
|
34
|
+
* remote caller and the tree spans both services.
|
|
35
|
+
*
|
|
36
|
+
* Unlike the agent's save/restore pattern, `als.run` restores the parent store
|
|
37
|
+
* automatically when `fn` returns or throws - the runtime does the "finally" for
|
|
38
|
+
* us.
|
|
39
|
+
*/
|
|
40
|
+
export declare function runWithTrace<T>(seed: {
|
|
41
|
+
traceId?: string;
|
|
42
|
+
parentSpanId?: string;
|
|
43
|
+
sampled?: boolean;
|
|
44
|
+
}, fn: () => T): T;
|
|
45
|
+
/**
|
|
46
|
+
* Run `fn` inside a CHILD span of the current trace (the current span becomes
|
|
47
|
+
* the parent). If no trace is active we mint one defensively, so a stray span
|
|
48
|
+
* still produces a usable (if un-parented) trace rather than blank ids.
|
|
49
|
+
*
|
|
50
|
+
* The child copies the parent's baggage by value, so a child's `bind()` cannot
|
|
51
|
+
* leak back up into the parent - matching the agent's copy-on-write contract.
|
|
52
|
+
*/
|
|
53
|
+
export declare function runWithSpan<T>(fn: () => T): T;
|
|
54
|
+
/**
|
|
55
|
+
* Stamp domain attributes onto the current scope so they appear on every later
|
|
56
|
+
* line. This deliberately MUTATES the live store's baggage (the one object
|
|
57
|
+
* AsyncLocalStorage hands back for the whole scope); it is a no-op outside a
|
|
58
|
+
* trace. Child scopes snapshot baggage at creation, so this never leaks across
|
|
59
|
+
* sibling requests.
|
|
60
|
+
*/
|
|
61
|
+
export declare function bind(attrs: Fields): void;
|
|
62
|
+
/**
|
|
63
|
+
* Parse a W3C `traceparent` header into `{ traceId, parentSpanId }`.
|
|
64
|
+
*
|
|
65
|
+
* Format: `version-traceid-spanid-flags`, e.g.
|
|
66
|
+
* `00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01`. Returns null for
|
|
67
|
+
* anything malformed so the caller mints a fresh root rather than trusting junk;
|
|
68
|
+
* the all-zero ids are explicitly invalid per the spec. This is the exact mirror
|
|
69
|
+
* of the agent's `parse_traceparent`, so each side accepts the other's output.
|
|
70
|
+
*/
|
|
71
|
+
export declare function parseTraceparent(header: string | null | undefined): {
|
|
72
|
+
traceId: string;
|
|
73
|
+
parentSpanId: string;
|
|
74
|
+
} | null;
|
|
75
|
+
/**
|
|
76
|
+
* Accept a plain UUID as a trace id.
|
|
77
|
+
*
|
|
78
|
+
* Some front ends pass a correlation UUID rather than a W3C
|
|
79
|
+
* `traceparent`, and a 32-hex UUID with the dashes stripped IS a valid trace
|
|
80
|
+
* id. Taking it means a user's journey is one trace from the front end through
|
|
81
|
+
* the launch to the lab, instead of two unconnected halves.
|
|
82
|
+
*
|
|
83
|
+
* Returns null for anything that is not 32 hex characters, and for the
|
|
84
|
+
* all-zero id, which W3C defines as "no trace".
|
|
85
|
+
*/
|
|
86
|
+
export declare function traceIdFromCorrelationId(raw: string | null | undefined): string | null;
|
|
87
|
+
/** Serialise ids into a W3C `traceparent` string for an outbound header. */
|
|
88
|
+
export declare function formatTraceparent(traceId: string, spanId: string, sampled: boolean): string;
|