@rootcat/node 0.1.0 → 0.2.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,83 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ **Alongside another tracer.** An application whose tracer owns the
6
+ process's OpenTelemetry setup (Sentry, tested with 8.55, 9.47 and 10.51;
7
+ an application's own `NodeSDK`) starts Rootcat after it with
8
+ `startAlongside()` from `@rootcat/node/attach`, and gives a tracer that
9
+ records spans the processors from `rootcatSpanProcessors()`; see
10
+ "Alongside another tracer" in the README. Modes: `own`, `beside`,
11
+ `inside`.
12
+
13
+ - Sentry for errors only (no `tracesSampleRate`): the package runs its
14
+ own provider beside Sentry's, never registered globally, under Sentry's
15
+ context manager. Its spans are recorded without the sampled flag, so
16
+ the headers Sentry propagates and the error events it sends make the
17
+ same sampling statement as before. Sentry receives no transaction and
18
+ no span it did not receive before; its error events now carry the
19
+ Rootcat trace id, linking an error to its trace. Outgoing requests
20
+ carry Sentry's headers byte for byte as before, made before, during or
21
+ after the response. One client report is no longer sent:
22
+ the one counting an outgoing request's span Sentry skipped for want of
23
+ a parent, which now has one. A Hono route that `@sentry/hono` names
24
+ becomes the server span's `http.route`.
25
+ - Sentry with tracing: `Sentry.init({ openTelemetrySpanProcessors:
26
+ rootcatSpanProcessors() })` puts the package's processor in Sentry's
27
+ provider. It exports a copy of every span Sentry records, with its
28
+ call site and the privacy rules, and leaves Sentry's spans as they
29
+ are. The package adds no span of its own; its Drizzle, pg pool and
30
+ postgres.js entry wrappers carry call sites to Sentry's query spans.
31
+ - Nothing global is registered before `Sentry.init` in an application
32
+ that depends on a Sentry server SDK (listed in `package.json`, or
33
+ installed when there is none): loaded before it (the import line,
34
+ `NODE_OPTIONS`) the package starts nothing; `startAlongside()` above it
35
+ starts beside Sentry at once and settles a tick later: beside, or
36
+ inside a tracing Sentry with nothing of its own left on `node:http`, or
37
+ stopped (handlers removed, loader hook idle, processors inert). One
38
+ line on stderr either way.
39
+ - Whether Sentry traces is read by its own rule per major version (8:
40
+ a `tracesSampleRate` key with no value is tracing on). Beside another
41
+ SDK, no wrapper of it is removed or wrapped over (Sentry 8's
42
+ `node:http`). The package patches `http` and `https` through the
43
+ CommonJS hook itself, so server spans are recorded with Sentry's and
44
+ the package's import-in-the-middle copies apart (pnpm). Its loader
45
+ hook takes over postgres.js's entry and Drizzle's query classes from
46
+ a hook that wraps every module (Sentry 8's).
47
+ - Beside Sentry, outgoing `fetch` and `http` requests carry Sentry's
48
+ headers once, with the baggage they had without the package; the
49
+ package's spans below another SDK's unrecorded span (Next.js under
50
+ `@sentry/nextjs`) are kept.
51
+ - Inside a tracing Sentry, the copies the package exports are cut to
52
+ what its own spans would hold: span names that are paths or URLs
53
+ masked, Redis statements reduced to the command, `cache.key` and
54
+ headers dropped, `url.fragment` emptied. The pg pool wrapper leaves
55
+ `connect` to Sentry, and a wrapper of the package never unwraps another
56
+ SDK's.
57
+ - A registered provider is recognised in the same copy of
58
+ `@opentelemetry/api` as well; before, only one from another copy was.
59
+
60
+ **Other changes.**
61
+
62
+ - postgres.js: the driver's own queries on a new connection (the array
63
+ type lookup, the read-only check) are no longer traced; they showed as
64
+ a second span, with the call site, for the query that opened the
65
+ connection.
66
+ - ioredis and redis: a command is traced only inside a traced operation.
67
+ A queue such as BullMQ polls Redis all the time outside any, which was
68
+ a trace per poll.
69
+ - Request and response header attributes and Sentry's `sentry.*` keys
70
+ are never exported; `http.query` and `db.connection_string` are
71
+ emptied like `url.query`.
72
+ - The ES module loader hook is an import-in-the-middle instance of the
73
+ package's own, never a second registration of the `hook.mjs` another
74
+ SDK registered (with one copy installed for both, that replaced the
75
+ other's list of modules to wrap); it wraps only postgres.js's entry file
76
+ and Drizzle's two query classes of those ES module packages (all of
77
+ their files before), and neither hook warns about the other.
78
+
79
+ ## 0.1.0
80
+
81
+ First release: the import line, `--require`/`--import` entries, the
82
+ curated instrumentations, the call-site processor, OTLP/HTTP protobuf
83
+ to the host agent.
package/README.md CHANGED
@@ -74,10 +74,74 @@ bridge network, should set `ROOTCAT_OTLP_URL=http://127.0.0.1:4318`.
74
74
  `ROOTCAT_OTLP_URL` overrides discovery. The service name is `ROOTCAT_SERVICE`,
75
75
  else `OTEL_SERVICE_NAME`, else the `name` of the nearest `package.json`.
76
76
 
77
- If another OpenTelemetry SDK is already registered in the process (Sentry
78
- 8 and later, an app's own `NodeSDK`), the package prints one line and
79
- does not start. If it starts first, the other SDK's registration fails
80
- instead; load one of them.
77
+ **Alongside another tracer** (Sentry, tested with 8.55, 9.47 and 10.51;
78
+ an app's own `NodeSDK`), the package starts after it: see Alongside
79
+ another tracer below.
80
+
81
+ ## Alongside another tracer
82
+
83
+ The package works alongside a tracer that owns the process's
84
+ OpenTelemetry setup (its tracer provider, the context manager, the
85
+ propagator): it starts after that tracer and takes none of them. Sentry 8
86
+ and later is the worked example. In the file that initialises Sentry:
87
+
88
+ ```ts
89
+ // src/instrument.ts (node --import ./dist/instrument.js ./dist/index.js)
90
+ import { startAlongside } from '@rootcat/node/attach';
91
+ import { initializeSentry } from './lib/sentry';
92
+
93
+ initializeSentry(); // Sentry.init({ ... })
94
+ startAlongside();
95
+ ```
96
+
97
+ With Sentry tracing on (`tracesSampleRate` or `tracesSampler`), also give
98
+ `Sentry.init` the package's processor:
99
+
100
+ ```ts
101
+ import { rootcatSpanProcessors } from '@rootcat/node/attach';
102
+
103
+ Sentry.init({ ...options, openTelemetrySpanProcessors: rootcatSpanProcessors() });
104
+ ```
105
+
106
+ - **Sentry for errors only**: the package runs its own provider beside
107
+ Sentry's, unregistered, with the curated instrumentations. Sentry sends
108
+ what it sent before: no transaction, no span, the same error events
109
+ with their request scope and `beforeSend`; each error event now carries
110
+ the Rootcat trace id of its request. Outgoing requests carry
111
+ `sentry-trace` and `baggage` byte for byte as without Rootcat (ids
112
+ aside), whether made before, during or after the response, so
113
+ downstream Sentry quotas and a payment provider's callbacks see what
114
+ they saw. The one item not sent is a client report of a span
115
+ Sentry skipped for want of a parent, which now has one. With
116
+ `@sentry/hono` the server span gets the route (`http.route`).
117
+ - **Sentry with tracing**: Sentry instruments the libraries; the package
118
+ adds no span and exports a copy of each span Sentry records, with its
119
+ call site, without changing what Sentry sends, cut to what the
120
+ package's own spans would hold (no headers, masked paths in span names,
121
+ Redis commands without keys). Sentry's sample rate decides which traces
122
+ exist; a query outside a request (a queue job) has no Sentry span, so
123
+ none here either.
124
+ - Nothing global is registered before `Sentry.init`: that would take the
125
+ context manager from Sentry and break its request isolation. Loaded
126
+ before it (the import line first, or `NODE_OPTIONS`), the package starts
127
+ nothing and says so in one line. `startAlongside()` above `Sentry.init`
128
+ starts beside Sentry at once and settles a tick later (stays beside,
129
+ goes inside a tracing Sentry leaving nothing of its own on `node:http`,
130
+ or stops, leaving nothing running), one line on stderr; the ES
131
+ module drivers may go untraced in that order, so call it after
132
+ `Sentry.init`.
133
+ - Trace context crosses services in Sentry's headers (`sentry-trace`,
134
+ `baggage`); Sentry's propagator does not read a W3C `traceparent`.
135
+
136
+ With an application's own `NodeSDK`, pass `rootcatSpanProcessors()` in
137
+ its `spanProcessors` and call `startAlongside()` after `sdk.start()`.
138
+ Tested with `@sentry/hono` 10.51 (npm, and a pnpm deployment as the
139
+ owner's API ships) and `@sentry/node` 8.55 and 9.47; expected to work
140
+ with the other Sentry server SDKs of those versions and any
141
+ OpenTelemetry SDK whose provider takes span processors; not with one whose provider does not (`dd-trace`), where the
142
+ package does not start and says so. The cost per request beside and
143
+ inside Sentry: see Cost. Details, and why not `skipOpenTelemetrySetup`:
144
+ "Alongside another tracer" in `docs/v4-node-package.md`.
81
145
 
82
146
  ## What is traced
83
147
 
@@ -86,11 +150,11 @@ instead; load one of them.
86
150
  | `http`, `https` | server and client requests | client requests | through Next.js |
87
151
  | `fetch`, `undici` | client requests | yes | no |
88
152
  | `pg` | one span per query; cursors and streams (`pg-cursor`, `pg-query-stream`) are not traced | yes, through the pool as well | yes |
89
- | `postgres` (postgres.js) | one span per query | tagged templates, and queries made through Drizzle | yes |
153
+ | `postgres` (postgres.js) | one span per query (not the driver's own queries on a new connection) | tagged templates, and queries made through Drizzle | yes |
90
154
  | `drizzle-orm` | the driver spans below it | yes, from where the query was built | yes, over `pg` and postgres.js |
91
155
  | `mysql2` | queries | yes | no |
92
156
  | `mongodb` | commands | yes | no |
93
- | `ioredis`, `redis` | commands | yes | no |
157
+ | `ioredis`, `redis` | commands inside a traced operation (a queue's polling outside one is not traced) | yes | with Sentry, through BullMQ |
94
158
  | `@prisma/client` | when the app installs `@prisma/instrumentation` | the operation's | no |
95
159
 
96
160
  Every client span carries `code.filepath`, `code.lineno` and
@@ -116,7 +180,8 @@ letters, hyphens and underscores up to 32 bytes (not a mixed-case run of
116
180
  twelve or more) or a version such as `v1`, so a username or slug made of
117
181
  letters (`/u/kelvin`) is sent, and numbers, UUIDs, tokens, emails and file
118
182
  names are not. Client addresses and user agents. Request
119
- and response bodies and headers. Environment variables and command lines.
183
+ and response bodies and headers (header attributes another SDK such as
184
+ Sentry records are dropped). Connection strings. Environment variables and command lines.
120
185
  A database server's error message (only the error class and SQLSTATE
121
186
  travel). A recorded exception's stack trace is sent as frames only.
122
187
 
@@ -160,6 +225,22 @@ Loading the package adds 0.3 to 0.4 s to the start of a process, about
160
225
  half of it the module loader hook (every module load waits on its
161
226
  thread).
162
227
 
228
+ Per request (`npm run bench:sentry`: a Hono route running one Drizzle
229
+ query, 2000 sequential requests after a warm-up, medians of six rounds;
230
+ ranges over three runs, which differ by up to a third on this machine):
231
+
232
+ | Setup | Server CPU added per request |
233
+ |---|---|
234
+ | Rootcat on its own (no Sentry) | +990 µs (+76 %) |
235
+ | Rootcat beside an errors-only Sentry | +1005 to +1220 µs (+65 to +88 %) |
236
+ | Rootcat inside a Sentry that traces every request | +235 to +535 µs (+5 to +15 %) |
237
+
238
+ The HTTP server span is the largest piece of a request's cost; budget
239
+ about 1 ms per request and 0.2 ms per query. Beside Sentry, about
240
+ 0.2 ms of it is Sentry's context manager, which clones a scope at every
241
+ context switch. Turning Sentry's own tracing on costs more (+2.9 ms on
242
+ the same route).
243
+
163
244
  ## When the agent is not there
164
245
 
165
246
  Spans are batched (every 10 s, up to 512 per export, 4096 queued; past
@@ -0,0 +1 @@
1
+ export { rootcatSpanProcessors, startAlongside, type Mode, type Started, type StartOptions } from './sdk.js';
package/dist/attach.js ADDED
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.startAlongside = exports.rootcatSpanProcessors = void 0;
4
+ // @rootcat/node/attach: Rootcat in an application where another tracer
5
+ // owns the process's OpenTelemetry setup (Sentry 8 and later, an app's
6
+ // own NodeSDK). In the file that sets that tracer up, after it:
7
+ //
8
+ // import { startAlongside } from "@rootcat/node/attach";
9
+ // Sentry.init({ ... });
10
+ // startAlongside();
11
+ //
12
+ // When that tracer records spans (Sentry with tracesSampleRate or
13
+ // tracesSampler, a NodeSDK), its provider also takes the package's
14
+ // processor:
15
+ //
16
+ // import { rootcatSpanProcessors, startAlongside } from "@rootcat/node/attach";
17
+ // Sentry.init({ ..., openTelemetrySpanProcessors: rootcatSpanProcessors() });
18
+ // startAlongside();
19
+ //
20
+ // See coexist.ts for what each case does.
21
+ var sdk_js_1 = require("./sdk.js");
22
+ Object.defineProperty(exports, "rootcatSpanProcessors", { enumerable: true, get: function () { return sdk_js_1.rootcatSpanProcessors; } });
23
+ Object.defineProperty(exports, "startAlongside", { enumerable: true, get: function () { return sdk_js_1.startAlongside; } });
24
+ //# sourceMappingURL=attach.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attach.js","sourceRoot":"","sources":["../src/attach.ts"],"names":[],"mappings":";;;AAAA,uEAAuE;AACvE,uEAAuE;AACvE,gEAAgE;AAChE,EAAE;AACF,2DAA2D;AAC3D,0BAA0B;AAC1B,sBAAsB;AACtB,EAAE;AACF,kEAAkE;AAClE,mEAAmE;AACnE,aAAa;AACb,EAAE;AACF,kFAAkF;AAClF,gFAAgF;AAChF,sBAAsB;AACtB,EAAE;AACF,0CAA0C;AAC1C,mCAA6G;AAApG,+GAAA,qBAAqB,OAAA;AAAE,wGAAA,cAAc,OAAA"}
@@ -0,0 +1,183 @@
1
+ import { type Context } from '@opentelemetry/api';
2
+ import type { Resource } from '@opentelemetry/resources';
3
+ import type { ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-base';
4
+ import { type CallSiteProcessor } from './processor.js';
5
+ /**
6
+ * ExistingSetup is what the package can tell about another tracer in the
7
+ * process. kind is 'sentry' when its options can be read (Sentry keeps its
8
+ * client on globalThis), 'other' when a provider is registered by an SDK
9
+ * whose options cannot be read.
10
+ */
11
+ export interface ExistingSetup {
12
+ kind: 'sentry' | 'other';
13
+ /** registered says a tracer provider is registered globally. */
14
+ registered: boolean;
15
+ /** tracing says the owner records spans (Sentry: tracesSampleRate or tracesSampler; another SDK: assumed). */
16
+ tracing: boolean;
17
+ /** debug says the owner runs the OpenTelemetry diagnostic logger (Sentry's debug option). */
18
+ debug: boolean;
19
+ /**
20
+ * processors says whether the owner was given rootcatSpanProcessors():
21
+ * read from Sentry's options, unknown (undefined) for another SDK.
22
+ */
23
+ processors: boolean | undefined;
24
+ }
25
+ /**
26
+ * existingSetup describes the tracer that owns the process's
27
+ * OpenTelemetry setup, or undefined when there is none: no provider is
28
+ * registered and no Sentry client exists.
29
+ */
30
+ export declare function existingSetup(): ExistingSetup | undefined;
31
+ type OwnerOptions = {
32
+ tracesSampleRate?: unknown;
33
+ tracesSampler?: unknown;
34
+ enableTracing?: unknown;
35
+ debug?: unknown;
36
+ openTelemetrySpanProcessors?: unknown;
37
+ };
38
+ /**
39
+ * tracesByVersion is Sentry's own rule for whether it records spans, by its
40
+ * major version, so the package never runs beside a Sentry that installed
41
+ * its performance instrumentations:
42
+ * - 8: hasTracingEnabled, enableTracing or a tracesSampleRate or
43
+ * tracesSampler key present, whatever its value (undefined included);
44
+ * - 9 and later: hasSpansEnabled, tracesSampleRate not null or undefined,
45
+ * or a tracesSampler (enableTracing was removed in 9).
46
+ * An unknown version is taken as tracing: inside mode, which adds no
47
+ * instrumentation of the package's own, rather than beside a Sentry that
48
+ * might have installed its own.
49
+ */
50
+ export declare function tracesByVersion(options: OwnerOptions, version: string): boolean;
51
+ /**
52
+ * providerRegistered reports whether a tracer provider is registered
53
+ * globally (by another SDK). The API's global is a proxy whose delegate
54
+ * is the registered provider, or the no-op one; a proxy from another
55
+ * copy of the API answers the same way.
56
+ */
57
+ export declare function providerRegistered(): boolean;
58
+ /**
59
+ * tracerDependency names the package of a tracer the application
60
+ * initialises later (laterTracer) that it depends on: the one the nearest
61
+ * package.json above cwd lists (at most eight
62
+ * levels, 64 KiB), else, when no readable package.json lists the
63
+ * application's dependencies (an image of a bundle and its node_modules),
64
+ * a Sentry SDK installed in a node_modules on the way up. undefined when
65
+ * neither.
66
+ */
67
+ export declare function tracerDependency(cwd: string): string | undefined;
68
+ /**
69
+ * UrlSource marks the package's spans beside a Sentry that traces
70
+ * nothing as named after a URL (sentry.source "url"), and keeps them so:
71
+ * Sentry writes the name of a span with any other source into the
72
+ * dynamic sampling context it propagates (sentry-transaction in baggage)
73
+ * and sends with error events. @sentry/hono names the route on the active
74
+ * span as the response goes out (source "route"), so a call made after
75
+ * the response (a payment callback fired in the background) would carry
76
+ * the route; a source another SDK sets is kept aside instead
77
+ * (rootcat.source, read by routeFromHost for http.route). Neither key is
78
+ * exported. Outgoing requests and error events then carry the dynamic
79
+ * sampling context they carried without the package, before, during and
80
+ * after the response.
81
+ */
82
+ export declare class UrlSource implements SpanProcessor {
83
+ onStart(span: Span): void;
84
+ onEnd(): void;
85
+ forceFlush(): Promise<void>;
86
+ shutdown(): Promise<void>;
87
+ }
88
+ /** keptSourceKey holds the source another SDK set on a pinned span. */
89
+ export declare const keptSourceKey = "rootcat.source";
90
+ type UndiciRequest = {
91
+ headers?: unknown;
92
+ addHeader?: (name: string, value: string) => unknown;
93
+ };
94
+ /** hasHeader reports whether an undici request already carries a header. */
95
+ export declare function hasHeader(request: UndiciRequest, name: string): boolean;
96
+ /**
97
+ * propagatedOnce makes an undici instrumentation leave a request's
98
+ * headers alone when Sentry's fetch instrumentation has already propagated
99
+ * (it subscribes to the same channel first, and from the same active
100
+ * span): the instrumentation would otherwise add a second sentry-trace
101
+ * and baggage, which a downstream service reads as one comma-joined
102
+ * value. The span still starts. The instrumentation subscribed to its
103
+ * channels when it was created; it is disabled and enabled again around
104
+ * the change, so the subscriptions take the new handler.
105
+ */
106
+ export declare function propagatedOnce<T extends object>(instrumentation: T): T;
107
+ /**
108
+ * FlaggedExport hands the spans the package records beside Sentry
109
+ * (recorded, not flagged sampled; see RecordOnlySampler) to an exporting
110
+ * processor as sampled views, which the batch processor exports.
111
+ */
112
+ export declare class FlaggedExport implements SpanProcessor {
113
+ private readonly inner;
114
+ constructor(inner: SpanProcessor);
115
+ onStart(span: Span, parentContext: Context): void;
116
+ onEnd(span: ReadableSpan): void;
117
+ forceFlush(): Promise<void>;
118
+ shutdown(): Promise<void>;
119
+ }
120
+ /**
121
+ * InProviderProcessor is the one processor the package puts in Sentry's
122
+ * provider: the read-only call-site processor, and a copy of each span
123
+ * Sentry records (and ROOTCAT_SAMPLE keeps) to the exporting processors,
124
+ * under the package's resource. Sentry's spans are never changed.
125
+ */
126
+ export declare class InProviderProcessor implements SpanProcessor {
127
+ readonly callSites: CallSiteProcessor;
128
+ private readonly exporting;
129
+ private readonly resource;
130
+ private readonly ratio;
131
+ constructor(callSites: CallSiteProcessor, exporting: SpanProcessor[], resource: Resource, ratio: number);
132
+ /** stopped is set by stop(): the processor then does nothing. */
133
+ private stopped;
134
+ /**
135
+ * stop makes the processor inert and flushes what it holds: the
136
+ * package stopped (startAlongside() found no tracer to run alongside)
137
+ * after this processor was given to a provider.
138
+ */
139
+ stop(): void;
140
+ onStart(span: Span, parentContext: Context): void;
141
+ onEnd(span: ReadableSpan): void;
142
+ forceFlush(): Promise<void>;
143
+ shutdown(): Promise<void>;
144
+ }
145
+ /**
146
+ * keeps applies ROOTCAT_SAMPLE to a trace Sentry records: the same share
147
+ * of trace ids the OpenTelemetry ratio sampler keeps, so every span of a
148
+ * trace gets the same answer.
149
+ */
150
+ export declare function keeps(traceId: string, ratio: number): boolean;
151
+ /**
152
+ * activeDatabaseSpan is the active span when it is a recorded database
153
+ * span (the one Sentry's postgres.js instrumentation starts just before the
154
+ * query is handled), the span a postgres.js origin belongs to; anything
155
+ * else (a server span, when Sentry skipped the query for want of a
156
+ * parent) is not given the query's site.
157
+ */
158
+ export declare function activeDatabaseSpan(): object | undefined;
159
+ /**
160
+ * guardWrappers keeps an instrumentation's hands off another SDK's
161
+ * wrappers. Upstream instrumentations unwrap whatever wrapper they find
162
+ * on a method before wrapping it (meant for their own second enable),
163
+ * which would remove another SDK's: Sentry 8's http instrumentation,
164
+ * behind its request isolation, for one. With the guard the
165
+ * instrumentation only ever unwraps the package's own wrappers, and
166
+ * marks its wrappers as the package's. With keepForeign (beside another
167
+ * SDK) it also leaves alone a method another SDK already wrapped: that
168
+ * SDK's instrumentation stays the only one, and the package records no
169
+ * span there.
170
+ */
171
+ export declare function guardWrappers<T extends object>(instrumentation: T, keepForeign: boolean): T;
172
+ /**
173
+ * sentryLoaderShared reports whether the application's Sentry and the
174
+ * package load one import-in-the-middle copy (npm dedupes them; pnpm
175
+ * keeps a copy each). Unknown counts as not shared. It decides how much
176
+ * the package's loader hook may wrap before Sentry.init: registered first,
177
+ * that hook sits inside Sentry's, and a module it marks for itself is
178
+ * then wrapped through its copy alone, so with copies apart Sentry's own
179
+ * instrumentation of that module (postgres.js, pg, with tracing on) would
180
+ * never run.
181
+ */
182
+ export declare function sentryLoaderShared(cwd: string): boolean;
183
+ export {};