@heystack/otel 0.4.1 → 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 +3 -0
- package/dist/node.d.ts +28 -0
- package/dist/node.js +82 -2
- package/dist/self-span.d.ts +30 -0
- package/dist/self-span.js +83 -0
- package/dist/workers.js +4 -70
- package/package.json +6 -1
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 ??
|
|
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
|
|
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
|
-
|
|
152
|
-
|
|
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.
|
|
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",
|
|
@@ -60,5 +60,10 @@
|
|
|
60
60
|
"typescript": "^5.7.0",
|
|
61
61
|
"@types/node": "^22.0.0",
|
|
62
62
|
"@cloudflare/workers-types": "^4"
|
|
63
|
+
},
|
|
64
|
+
"repository": {
|
|
65
|
+
"type": "git",
|
|
66
|
+
"url": "git+https://github.com/heystack-hq/heystack.git",
|
|
67
|
+
"directory": "packages/otel-sdk"
|
|
63
68
|
}
|
|
64
69
|
}
|