@heystack/otel 0.4.2 → 0.4.3

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/README.md CHANGED
@@ -200,8 +200,11 @@ As of **0.3.1** the exporter **suppresses tracing for its own ingest POST**, and
200
200
 
201
201
  As belt-and-suspenders the exporter also drops any span whose HTTP target points at the configured ingest origin. As of **0.3.2** that match is **hostname-accurate**: full-URL attributes (`url.full`, `http.url`) are parsed and compared on `.hostname` (case-insensitive, port-stripped) so a sibling domain like `myingest.heystack.dev` is no longer a false positive and an explicit port like `ingest.heystack.dev:443` is correctly matched; host-only attributes (`server.address`, `net.peer.name`, `net.peer.hostname`, `http.host`, `peer.address`) are port-stripped and compared by hostname.
202
202
 
203
+ **The `/node` (and Next-on-Node) path got the same guard in `0.4.3`.** Before then, only the Workers/Edge exporter dropped self-spans; on a plain Node runtime the OTLP-over-`http` exporter's own `POST /v1/traces` was still captured by the HTTP/undici auto-instrumentation and re-exported, so the **same feedback loop ran on Node** (production measurement: ~77% of all ingested spans were `fetch POST .../v1/traces`). `0.4.3` (a) configures the HTTP + undici auto-instrumentations to ignore outbound requests to the ingest host, and (b) wraps the exporter so any span targeting the ingest origin is filtered before export — the latter holds even if you pass your own `instrumentations` array. Same hostname-accurate matcher as the Workers path (shared module). No API change; upgrade and redeploy.
204
+
203
205
  ## Migration / versioning
204
206
 
207
+ - **`0.4.3`** — **feedback-loop guard extended to the Node path (cost fix).** The self-instrumentation loop the Workers path fixed in 0.3.1/0.3.2 was still live on plain Node / Next-on-Node: the OTLP-over-`http` exporter's `POST /v1/traces` was auto-instrumented and re-exported, so ~77% of ingested spans in production were the exporter tracing itself — needless ingest + ClickHouse compute. 0.4.3 ignores ingest-host requests in the HTTP/undici auto-instrumentations **and** filters self-spans at the exporter boundary (covers caller-supplied `instrumentations` too). The hostname matcher is now a shared module used by both `/node` and `/workers`. No API change. **Action: upgrade and redeploy any Node/Next-on-Node app** — it cuts ingested span volume sharply.
205
208
  - **`0.3.5`** — **type-constraint fix (Workers).** A Worker whose `queue` consumer is typed with a concrete message body — `queue(batch: MessageBatch<MyJob>, …)`, the normal case — failed to compile against `instrument()` in 0.3.4 (`TS2345`: `MessageBatch<unknown>` not assignable to `MessageBatch<MyJob>`). The `WorkerHandler` constraint declared its entrypoints as arrow properties, whose parameters are checked **contravariantly** under `strictFunctionTypes`, so a narrowed handler wasn't assignable. 0.3.5 declares them with **method syntax** (bivariant parameters) and widens the batch to `MessageBatch<any>`, mirroring Cloudflare's own `ExportedHandler` — a typed-queue Worker now type-checks with a bare `instrument(handler, cfg)`. Runtime behaviour unchanged. Also adds a **consumer type-check gate** (`pnpm consumer-typecheck`, run in `check` and `prepublishOnly`) that compiles a fully-typed Worker against the built `dist` through the public `exports` map and asserts `satisfies ExportedHandler<Env>` — the regression that escaped in 0.3.3/0.3.4 now fails the build before publish.
206
209
  - **`0.3.4`** — **type-inference fix (Workers).** Restores `instrument()`'s ability to infer the handler's concrete `Env` type. In 0.3.3 the signature was `instrument<E = unknown, H extends WorkerHandler<E>>`, so `E` defaulted to `unknown` and was never recovered from the handler — under `strictFunctionTypes` a Worker typed `fetch(req, env: Env, ctx)` then failed to compile (`TS2345: 'Env' is not assignable to 'unknown'`) unless the caller passed `instrument<Env>(...)` explicitly. 0.3.4 infers `E` from the handler argument (`instrument<H extends WorkerHandler<any>>(...): Instrumented<EnvOf<H>, H>`), so a bare `instrument(handler, cfg)` type-checks again. Runtime behaviour is unchanged; no `0.3.3` consumer needs the explicit type arg after upgrading.
207
210
  - **`0.3.3`** — `/next` uses bare, exports-mapped dynamic imports so the OpenNext (Cloudflare Pages) build resolves the workers entry correctly (fixes a build break). Workers/Node paths unchanged.
package/dist/node.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { NodeSDK } from "@opentelemetry/sdk-node";
2
2
  import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
3
+ import { type ExportResult } from "@opentelemetry/core";
4
+ import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base";
3
5
  import { type HeystackOptions } from "./core.js";
4
6
  /**
5
7
  * The element type the NodeSDK `instrumentations` config accepts (an OTel
@@ -24,6 +26,32 @@ export interface NodeOptions extends HeystackOptions {
24
26
  */
25
27
  instrumentations?: InstrumentationConfigItem[];
26
28
  }
29
+ /**
30
+ * Wraps a span exporter and drops any span that targets the Heystack ingest
31
+ * origin before it is exported. On Node/Next the runtime auto-instruments
32
+ * outbound HTTP, so the exporter's own POST to `/v1/traces` becomes a CLIENT
33
+ * span → exported → re-captured → a sustained self-feeding loop. We measured
34
+ * this loop accounting for ~77% of ingested spans in production.
35
+ *
36
+ * The `/workers` entry stops the loop at the exporter via a suppressed context;
37
+ * Node's OTLP-over-`http` exporter cannot rely on that being honoured by every
38
+ * instrumentation version, so we filter at the exporter boundary instead. This
39
+ * is the universal guard — it works regardless of which instrumentations are
40
+ * loaded (including a caller-supplied `instrumentations` array). The
41
+ * `ignore*RequestHook` config below is the cheaper primary defence that stops
42
+ * the self-span from being created in the first place.
43
+ */
44
+ export declare class SelfSpanFilteringExporter implements SpanExporter {
45
+ private readonly inner;
46
+ /** Bare ingest hostname (lower-case, no port), e.g. `ingest.heystack.dev`. */
47
+ private readonly ingestHost;
48
+ constructor(inner: SpanExporter,
49
+ /** Bare ingest hostname (lower-case, no port), e.g. `ingest.heystack.dev`. */
50
+ ingestHost: string);
51
+ export(spans: ReadableSpan[], cb: (result: ExportResult) => void): void;
52
+ shutdown(): Promise<void>;
53
+ forceFlush(): Promise<void>;
54
+ }
27
55
  /** Initialise Heystack tracing on a Node runtime. Call once, as early as possible. Returns the started SDK. */
28
56
  export declare function initHeystack(o: NodeOptions): NodeSDK;
29
57
  /** Flush + shutdown the SDK on SIGTERM/SIGINT so short-lived processes don't lose the last batch. Registers handlers at most once. */
package/dist/node.js CHANGED
@@ -2,7 +2,78 @@ import { NodeSDK } from "@opentelemetry/sdk-node";
2
2
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
3
3
  import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
4
4
  import { diag, DiagConsoleLogger, DiagLogLevel } from "@opentelemetry/api";
5
+ import { ExportResultCode } from "@opentelemetry/core";
5
6
  import { buildExporterConfig } from "./core.js";
7
+ import { isSelfSpanAttrs, safeHostname } from "./self-span.js";
8
+ /**
9
+ * Wraps a span exporter and drops any span that targets the Heystack ingest
10
+ * origin before it is exported. On Node/Next the runtime auto-instruments
11
+ * outbound HTTP, so the exporter's own POST to `/v1/traces` becomes a CLIENT
12
+ * span → exported → re-captured → a sustained self-feeding loop. We measured
13
+ * this loop accounting for ~77% of ingested spans in production.
14
+ *
15
+ * The `/workers` entry stops the loop at the exporter via a suppressed context;
16
+ * Node's OTLP-over-`http` exporter cannot rely on that being honoured by every
17
+ * instrumentation version, so we filter at the exporter boundary instead. This
18
+ * is the universal guard — it works regardless of which instrumentations are
19
+ * loaded (including a caller-supplied `instrumentations` array). The
20
+ * `ignore*RequestHook` config below is the cheaper primary defence that stops
21
+ * the self-span from being created in the first place.
22
+ */
23
+ export class SelfSpanFilteringExporter {
24
+ inner;
25
+ ingestHost;
26
+ constructor(inner,
27
+ /** Bare ingest hostname (lower-case, no port), e.g. `ingest.heystack.dev`. */
28
+ ingestHost) {
29
+ this.inner = inner;
30
+ this.ingestHost = ingestHost;
31
+ }
32
+ export(spans, cb) {
33
+ const kept = this.ingestHost
34
+ ? spans.filter((s) => !isSelfSpanAttrs(s.attributes, this.ingestHost))
35
+ : spans;
36
+ // Nothing left to send: report success without a no-op POST (which would
37
+ // itself be a self-span and re-prime the loop).
38
+ if (kept.length === 0) {
39
+ cb({ code: ExportResultCode.SUCCESS });
40
+ return;
41
+ }
42
+ this.inner.export(kept, cb);
43
+ }
44
+ shutdown() {
45
+ return this.inner.shutdown();
46
+ }
47
+ forceFlush() {
48
+ return this.inner.forceFlush?.() ?? Promise.resolve();
49
+ }
50
+ }
51
+ /**
52
+ * Per-instrumentation config that tells the HTTP + undici auto-instrumentations
53
+ * to skip outbound requests to the ingest host, so the exporter's own POST is
54
+ * never turned into a span. Keyed by instrumentation package name; unknown keys
55
+ * are ignored by `getNodeAutoInstrumentations`, so this stays version-tolerant.
56
+ */
57
+ function ingestIgnoreConfig(ingestHost) {
58
+ if (!ingestHost)
59
+ return {};
60
+ return {
61
+ "@opentelemetry/instrumentation-http": {
62
+ // RequestOptions carries host/hostname (and sometimes a full URL).
63
+ ignoreOutgoingRequestHook: (options) => {
64
+ const h = (options.hostname ?? options.host ?? "")
65
+ .toString()
66
+ .toLowerCase();
67
+ // host may include :port — compare on the bare hostname.
68
+ return h.split(":")[0] === ingestHost;
69
+ },
70
+ },
71
+ "@opentelemetry/instrumentation-undici": {
72
+ // Undici/fetch request: `.origin` is a full URL string, `.path` the path.
73
+ ignoreRequestHook: (req) => safeHostname(req.origin ?? "") === ingestHost,
74
+ },
75
+ };
76
+ }
6
77
  /**
7
78
  * Process-level guard so SIGTERM/SIGINT handlers are registered AT MOST ONCE,
8
79
  * even if `initHeystack` is called repeatedly (e.g. Next dev server reloads).
@@ -26,12 +97,21 @@ export function initHeystack(o) {
26
97
  if (o.debug)
27
98
  diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG);
28
99
  const cfg = buildExporterConfig(o);
100
+ const ingestHost = safeHostname(cfg.url);
101
+ // Primary defence: don't even create a span for the exporter's own POST.
102
+ // Only applies to our default auto-instrumentation set — a caller-supplied
103
+ // `instrumentations` array is left untouched (the exporter filter still
104
+ // protects it).
29
105
  const instrumentations = o.autoInstrument === false
30
106
  ? []
31
- : (o.instrumentations ?? [getNodeAutoInstrumentations()]);
107
+ : (o.instrumentations ??
108
+ [getNodeAutoInstrumentations(ingestIgnoreConfig(ingestHost))]);
109
+ // Universal defence: filter any self-span at the exporter boundary, so the
110
+ // loop is killed regardless of which instrumentations are active.
111
+ const traceExporter = new SelfSpanFilteringExporter(new OTLPTraceExporter({ url: cfg.url, headers: cfg.headers }), ingestHost);
32
112
  const sdk = new NodeSDK({
33
113
  serviceName: o.service,
34
- traceExporter: new OTLPTraceExporter({ url: cfg.url, headers: cfg.headers }),
114
+ traceExporter,
35
115
  instrumentations,
36
116
  });
37
117
  sdk.start();
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Parse the hostname (no port) out of a URL, lower-cased; empty string if it
3
+ * can't be parsed. We compare on hostname rather than `host` so that an ingest
4
+ * URL like `ingest.heystack.dev` matches a captured span attribute of
5
+ * `ingest.heystack.dev:443` (and vice-versa).
6
+ */
7
+ export declare function safeHostname(url: string): string;
8
+ /**
9
+ * Strip a trailing `:port` from a bare host attribute and lower-case it, so a
10
+ * host-only attr like `ingest.heystack.dev:443` compares equal to the ingest
11
+ * hostname. IPv6 literals (`[::1]:443`) keep their bracketed form. Returns ""
12
+ * for anything that isn't a non-empty string.
13
+ */
14
+ export declare function hostnameOf(hostAttr: unknown): string;
15
+ /** Attributes that carry a full HTTP URL on a CLIENT/SERVER span. */
16
+ export declare const HTTP_URL_ATTRS: readonly ["url.full", "http.url"];
17
+ /** Host-only attributes (host[:port], no scheme/path). */
18
+ export declare const HTTP_HOST_ATTRS: readonly ["server.address", "net.peer.name", "net.peer.hostname", "http.host", "peer.address"];
19
+ /**
20
+ * True if `attrs` looks like a request to the configured ingest origin — i.e. it
21
+ * is (or could be) the exporter's own self-trace. For full-URL attributes we
22
+ * parse the URL and compare its `.hostname` (case-insensitive, port stripped) so
23
+ * a sibling domain like `myingest.heystack.dev` is NOT a false positive and an
24
+ * explicit port like `ingest.heystack.dev:443` IS matched. For host-only attrs
25
+ * we strip any `:port` and compare hostname equality.
26
+ *
27
+ * `ingestHost` must be a bare hostname (lower-case, no port), as produced by
28
+ * `safeHostname(cfg.url)`.
29
+ */
30
+ export declare function isSelfSpanAttrs(attrs: Record<string, unknown>, ingestHost: string): boolean;
@@ -0,0 +1,83 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Self-span detection (shared by /workers and /node)
3
+ //
4
+ // When a runtime auto-instruments outbound HTTP (undici/fetch on Next/OpenNext,
5
+ // the http module on Node), the exporter's own POST to `/v1/traces` becomes a
6
+ // CLIENT span → exported → re-captured → a sustained loop. These helpers detect
7
+ // a span that targets the configured ingest origin so we can drop it before it
8
+ // feeds the loop. Pure string logic — no runtime imports — safe in any entry
9
+ // (including the WinterCG-only `/workers` build).
10
+ // ---------------------------------------------------------------------------
11
+ /**
12
+ * Parse the hostname (no port) out of a URL, lower-cased; empty string if it
13
+ * can't be parsed. We compare on hostname rather than `host` so that an ingest
14
+ * URL like `ingest.heystack.dev` matches a captured span attribute of
15
+ * `ingest.heystack.dev:443` (and vice-versa).
16
+ */
17
+ export function safeHostname(url) {
18
+ try {
19
+ return new URL(url).hostname.toLowerCase();
20
+ }
21
+ catch {
22
+ return "";
23
+ }
24
+ }
25
+ /**
26
+ * Strip a trailing `:port` from a bare host attribute and lower-case it, so a
27
+ * host-only attr like `ingest.heystack.dev:443` compares equal to the ingest
28
+ * hostname. IPv6 literals (`[::1]:443`) keep their bracketed form. Returns ""
29
+ * for anything that isn't a non-empty string.
30
+ */
31
+ export function hostnameOf(hostAttr) {
32
+ if (typeof hostAttr !== "string" || hostAttr === "")
33
+ return "";
34
+ const v = hostAttr.trim();
35
+ // Bracketed IPv6, optionally with a port: `[::1]` or `[::1]:443`.
36
+ if (v.startsWith("[")) {
37
+ const close = v.indexOf("]");
38
+ if (close !== -1)
39
+ return v.slice(0, close + 1).toLowerCase();
40
+ return v.toLowerCase();
41
+ }
42
+ // Strip a single trailing :port (host:port). A bare hostname has no colon.
43
+ const colon = v.lastIndexOf(":");
44
+ if (colon !== -1 && v.indexOf(":") === colon) {
45
+ return v.slice(0, colon).toLowerCase();
46
+ }
47
+ return v.toLowerCase();
48
+ }
49
+ /** Attributes that carry a full HTTP URL on a CLIENT/SERVER span. */
50
+ export const HTTP_URL_ATTRS = ["url.full", "http.url"];
51
+ /** Host-only attributes (host[:port], no scheme/path). */
52
+ export const HTTP_HOST_ATTRS = [
53
+ "server.address",
54
+ "net.peer.name",
55
+ "net.peer.hostname",
56
+ "http.host",
57
+ "peer.address",
58
+ ];
59
+ /**
60
+ * True if `attrs` looks like a request to the configured ingest origin — i.e. it
61
+ * is (or could be) the exporter's own self-trace. For full-URL attributes we
62
+ * parse the URL and compare its `.hostname` (case-insensitive, port stripped) so
63
+ * a sibling domain like `myingest.heystack.dev` is NOT a false positive and an
64
+ * explicit port like `ingest.heystack.dev:443` IS matched. For host-only attrs
65
+ * we strip any `:port` and compare hostname equality.
66
+ *
67
+ * `ingestHost` must be a bare hostname (lower-case, no port), as produced by
68
+ * `safeHostname(cfg.url)`.
69
+ */
70
+ export function isSelfSpanAttrs(attrs, ingestHost) {
71
+ if (!ingestHost)
72
+ return false;
73
+ for (const key of HTTP_URL_ATTRS) {
74
+ const v = attrs[key];
75
+ if (typeof v === "string" && safeHostname(v) === ingestHost)
76
+ return true;
77
+ }
78
+ for (const key of HTTP_HOST_ATTRS) {
79
+ if (hostnameOf(attrs[key]) === ingestHost)
80
+ return true;
81
+ }
82
+ return false;
83
+ }
package/dist/workers.js CHANGED
@@ -14,6 +14,7 @@ import { Resource } from "@opentelemetry/resources";
14
14
  import { BasicTracerProvider, SimpleSpanProcessor, } from "@opentelemetry/sdk-trace-base";
15
15
  import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
16
16
  import { buildExporterConfig } from "./core.js";
17
+ import { isSelfSpanAttrs, safeHostname } from "./self-span.js";
17
18
  // `ExportResult` / `ExportResultCode` mirror `@opentelemetry/core`. We define
18
19
  // them inline (structurally identical) rather than import them: core is only a
19
20
  // transitive dep of sdk-trace-base and isn't reliably resolvable, and keeping it
@@ -147,76 +148,9 @@ export function serializeSpans(spans) {
147
148
  // targets the ingest origin, so an upstream instrumentation that ignores
148
149
  // suppression still can't feed the loop.
149
150
  // ---------------------------------------------------------------------------
150
- /**
151
- * Parse the hostname (no port) out of a URL, lower-cased; empty string if it
152
- * can't be parsed. We compare on hostname rather than `host` so that an ingest
153
- * URL like `ingest.heystack.dev` matches a captured span attribute of
154
- * `ingest.heystack.dev:443` (and vice-versa).
155
- */
156
- function safeHostname(url) {
157
- try {
158
- return new URL(url).hostname.toLowerCase();
159
- }
160
- catch {
161
- return "";
162
- }
163
- }
164
- /**
165
- * Strip a trailing `:port` from a bare host attribute and lower-case it, so a
166
- * host-only attr like `ingest.heystack.dev:443` compares equal to the ingest
167
- * hostname. IPv6 literals (`[::1]:443`) keep their bracketed form. Returns ""
168
- * for anything that isn't a non-empty string.
169
- */
170
- function hostnameOf(hostAttr) {
171
- if (typeof hostAttr !== "string" || hostAttr === "")
172
- return "";
173
- const v = hostAttr.trim();
174
- // Bracketed IPv6, optionally with a port: `[::1]` or `[::1]:443`.
175
- if (v.startsWith("[")) {
176
- const close = v.indexOf("]");
177
- if (close !== -1)
178
- return v.slice(0, close + 1).toLowerCase();
179
- return v.toLowerCase();
180
- }
181
- // Strip a single trailing :port (host:port). A bare hostname has no colon.
182
- const colon = v.lastIndexOf(":");
183
- if (colon !== -1 && v.indexOf(":") === colon) {
184
- return v.slice(0, colon).toLowerCase();
185
- }
186
- return v.toLowerCase();
187
- }
188
- /** Attributes that carry a full HTTP URL on a CLIENT/SERVER span. */
189
- const HTTP_URL_ATTRS = ["url.full", "http.url"];
190
- /** Host-only attributes (host[:port], no scheme/path). */
191
- const HTTP_HOST_ATTRS = [
192
- "server.address",
193
- "net.peer.name",
194
- "net.peer.hostname",
195
- "http.host",
196
- "peer.address",
197
- ];
198
- /**
199
- * True if `span` looks like a request to the configured ingest origin — i.e. it
200
- * is (or could be) the exporter's own self-trace. For full-URL attributes we
201
- * parse the URL and compare its `.hostname` (case-insensitive, port stripped) so
202
- * a sibling domain like `myingest.heystack.dev` is NOT a false positive and an
203
- * explicit port like `ingest.heystack.dev:443` IS matched. For host-only attrs
204
- * we strip any `:port` and compare hostname equality.
205
- */
206
- function isSelfSpanAttrs(attrs, ingestHost) {
207
- if (!ingestHost)
208
- return false;
209
- for (const key of HTTP_URL_ATTRS) {
210
- const v = attrs[key];
211
- if (typeof v === "string" && safeHostname(v) === ingestHost)
212
- return true;
213
- }
214
- for (const key of HTTP_HOST_ATTRS) {
215
- if (hostnameOf(attrs[key]) === ingestHost)
216
- return true;
217
- }
218
- return false;
219
- }
151
+ // `safeHostname` / `isSelfSpanAttrs` live in the shared, runtime-agnostic
152
+ // `./self-span.js` module (also used by `/node`). They are pure string logic
153
+ // with no runtime imports, so importing them keeps this entry WinterCG-safe.
220
154
  function isSelfSpan(span, ingestHost) {
221
155
  return isSelfSpanAttrs(span.attributes, ingestHost);
222
156
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heystack/otel",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Runtime-aware OpenTelemetry tracing that exports to Heystack (Node, Next.js, Workers).",
5
5
  "license": "MIT",
6
6
  "type": "module",