@capxul/observability 4.1.4 → 4.2.0-rc.10

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
@@ -26,23 +26,28 @@ optional and snake_case; the camelCase spellings are retired.
26
26
  One boundary, two named postures, both built on the same sanitizer, token
27
27
  guard and missing-key opt-out:
28
28
 
29
- | | `utilityAppAnalytics` | `productAppAnalytics` |
30
- | --------------- | ------------------------------------------ | --------------------------------------- |
31
- | For | capability-URL apps (approval links, docs) | the Capxul product web app |
32
- | Persistence | off | **on** — J1 spans an OTP reload |
33
- | Person profiles | identified-only, nothing identifies | identified-only, identify-driven |
34
- | Autocapture | off | off |
35
- | Session replay | off | **on in every environment**, OTP masked |
36
- | Exceptions | off | on |
37
-
38
- Both stamp `capxul_env` and `producer: "browser"` through `before_send`, which
39
- also recursively removes URL query strings/fragments (including exception-frame
40
- filenames) and promoted campaign/search values. Pair either with
41
- `createPostHogBrowserPageviewTracker`, which reports pathname-only initial
42
- loads and SPA route changes. Vendor initialization and pageview capture
43
- failures degrade to disabled analytics. The product profile records ordinary
44
- text and input values. It masks inputs whose `autocomplete` attribute is
45
- `one-time-code`.
29
+ | | `utilityAppAnalytics` | `productAppAnalytics` |
30
+ | --------------- | ------------------------------------------ | ----------------------------------------------- |
31
+ | For | capability-URL apps (approval links, docs) | the Capxul product web app |
32
+ | Persistence | off | **on** — J1 spans an OTP reload |
33
+ | Person profiles | identified-only, nothing identifies | identified-only, identify-driven |
34
+ | Autocapture | off | off |
35
+ | Session replay | off | **on in every environment**, credentials masked |
36
+ | Exceptions | off | on |
37
+
38
+ Both stamp `capxul_env` and `producer: "browser"` through `before_send`.
39
+ Utility analytics removes URL queries, fragments, and promoted campaign/search
40
+ values. Product analytics preserves safe URL components and masks credentials.
41
+ Both profiles preserve the PostHog routing token required for transport.
42
+ `createPostHogBrowserPageviewTracker` reports pathname-only initial loads and
43
+ SPA route changes. Vendor initialization and pageview capture failures degrade
44
+ to disabled analytics.
45
+
46
+ [The redaction rule](../../docs/rules/redaction.md) defines the data classes.
47
+ Product replay masks password, OTP, and credential controls. Ordinary input
48
+ remains visible. Network copies mask credential fields, headers, and URL parts
49
+ while retaining safe codes, public chain evidence, and replay identifiers.
50
+ Existing event schemas and the four auth-event email hashes remain unchanged.
46
51
 
47
52
  ### Import the standard `posthog-js` build
48
53
 
@@ -0,0 +1,11 @@
1
+ {
2
+ "source": {
3
+ "commit": "a7e55e5f1c2af5567a8b285a4ef70b8b5a3289f6",
4
+ "tree": "91bf1b080c0006fe30131711c13c843afd627158",
5
+ "branch": "codex/programme-rc",
6
+ "repository": "https://github.com/Xelmar-tech/infrastructure",
7
+ "lockfileSha256": "5870f53b72706d103220c1bcf733cb4fb71196aea32a9cfbdc277d73cc959264"
8
+ },
9
+ "name": "@capxul/observability",
10
+ "version": "4.2.0-rc.10"
11
+ }
@@ -12,7 +12,7 @@ interface EngineeringTelemetryConfig {
12
12
  readonly sdkVersion: string;
13
13
  readonly serviceName?: string;
14
14
  }
15
- declare const traceHeaderFilter: (name: string) => boolean;
15
+ declare const traceHeaderFilter: (_name: string) => boolean;
16
16
  declare const postHogOtlpEndpoints: (host: string) => {
17
17
  readonly logs: `${string}/i/v1/logs`;
18
18
  readonly traces: `${string}/i/v1/traces`;
@@ -1,4 +1,5 @@
1
- import { Cause, Effect, Exit, Layer, Tracer } from "effect";
1
+ import { i as redactUrlSecrets, n as isCredentialField, r as redactSecrets } from "./src-CWfRYsBI.mjs";
2
+ import { Cause, Effect, Exit, FiberSet, Layer, Stream, Tracer } from "effect";
2
3
  import { FetchHttpClient, Headers, HttpClient } from "effect/unstable/http";
3
4
  import { OtlpExporter, OtlpLogger, OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
4
5
  //#region src/engineering.ts
@@ -8,21 +9,8 @@ const ENGINEERING_CAPXUL_ENVS = [
8
9
  "production",
9
10
  "local"
10
11
  ];
11
- const SAFE_TRACED_HEADER_NAMES = [
12
- "content-length",
13
- "content-type",
14
- "traceparent",
15
- "tracestate",
16
- "x-request-id"
17
- ];
18
- const ENGINEERING_REDACTED_HEADER_NAMES = Object.freeze([
19
- "authorization",
20
- "cookie",
21
- "set-cookie",
22
- "x-api-key",
23
- /auth|email|key|otp|secret|session|token|wallet/i
24
- ]);
25
- const traceHeaderFilter = (name) => SAFE_TRACED_HEADER_NAMES.includes(name.toLowerCase());
12
+ const traceHeaderFilter = (_name) => true;
13
+ const credentialHeaderPattern = Object.assign(/./u, { test: (name) => isCredentialField(name, "header") });
26
14
  const postHogOtlpEndpoints = (host) => {
27
15
  const base = host.replace(/\/+$/, "");
28
16
  return {
@@ -54,23 +42,79 @@ const validateEngineeringTelemetryConfig = (config) => {
54
42
  headers: Object.freeze({ authorization: headerEntries[0][1] })
55
43
  };
56
44
  };
57
- var RedactedEngineeringSpanFailure = class extends Error {
58
- constructor() {
59
- super("Engineering operation failed");
60
- this.name = "RedactedEngineeringSpanFailure";
61
- delete this.stack;
45
+ const failureText = (failure, key, limit, source) => {
46
+ if (failure === null || typeof failure !== "object") return void 0;
47
+ try {
48
+ let owner = failure;
49
+ let descriptor;
50
+ for (let depth = 0; owner !== null && depth < 4; depth += 1) {
51
+ descriptor = Object.getOwnPropertyDescriptor(owner, key);
52
+ if (descriptor !== void 0 || key !== "name") break;
53
+ owner = Object.getPrototypeOf(owner);
54
+ }
55
+ const value = descriptor?.value;
56
+ return typeof value === "string" || key === "code" && typeof value === "number" ? redactSecrets(String(value), source).slice(0, limit) : void 0;
57
+ } catch {
58
+ return;
59
+ }
60
+ };
61
+ const exportFailure = (failure, source) => {
62
+ const message = failureText(failure, "message", 2048, source) ?? (typeof failure === "string" ? redactSecrets(failure, source).slice(0, 2048) : "Engineering operation failed");
63
+ const code = failureText(failure, "code", 128);
64
+ const error = new Error((code === void 0 ? message : `${message} [code=${code}]`).slice(0, 2048));
65
+ error.name = failureText(failure, "_tag", 128) ?? failureText(failure, "name", 128) ?? "Error";
66
+ error.stack = failureText(failure, "stack", 8192, source) ?? `${error.name}: ${error.message}`;
67
+ return error;
68
+ };
69
+ const exportFailureExit = (cause, source) => Exit.failCause(Cause.fromReasons(cause.reasons.filter((reason) => !Cause.isInterruptReason(reason)).slice(0, 8).map((reason) => Cause.isFailReason(reason) ? Cause.makeFailReason(exportFailure(reason.error, source)) : Cause.makeDieReason(exportFailure(reason.defect, source)))));
70
+ const BROWSER_KEEPALIVE_BYTES = 65536;
71
+ let unfinishedBrowserExportBytes = 0;
72
+ const hasBrowserDocument = () => typeof window !== "undefined" && typeof document !== "undefined";
73
+ const browserExportClient = (client) => HttpClient.transform(client, (response, request) => Effect.acquireUseRelease(Effect.sync(() => {
74
+ const bytes = "contentLength" in request.body ? request.body.contentLength : void 0;
75
+ if (request.body._tag === "Stream" || request.body._tag === "FormData" || bytes === void 0 || !Number.isSafeInteger(bytes) || bytes < 0 || unfinishedBrowserExportBytes + bytes > BROWSER_KEEPALIVE_BYTES) return;
76
+ unfinishedBrowserExportBytes += bytes;
77
+ return bytes;
78
+ }), (bytes) => response.pipe(Effect.provideService(FetchHttpClient.RequestInit, { keepalive: bytes !== void 0 }), Effect.tap((result) => bytes === void 0 ? Effect.void : Stream.runDrain(result.stream).pipe(Effect.catch((error) => error.reason._tag === "EmptyBodyError" ? Effect.void : Effect.fail(error))))), (bytes) => Effect.sync(() => {
79
+ if (bytes !== void 0) unfinishedBrowserExportBytes -= bytes;
80
+ })));
81
+ const spanUpstream = (span) => {
82
+ let current = span;
83
+ while (current?._tag === "Span") {
84
+ const upstream = current.attributes.get("chain.upstream");
85
+ if (upstream === "alchemy" || upstream === "infura") return upstream;
86
+ current = current.parent._tag === "Some" ? current.parent.value : void 0;
62
87
  }
63
88
  };
64
- const REDACTED_SPAN_FAILURE = Exit.fail(new RedactedEngineeringSpanFailure());
65
89
  /** Preserve domain exits while preventing the OTLP serializer from seeing raw causes. */
66
90
  const makeLeakSafeEngineeringTracer = (delegate) => Tracer.make({
67
91
  span(options) {
68
92
  const span = delegate.span(options);
69
93
  const wrapped = Object.create(span);
94
+ Object.defineProperty(wrapped, "attribute", { value: (name, value) => {
95
+ const source = spanUpstream(span);
96
+ const header = /^http\.(?:request|response)\.header\.(.+)$/u.exec(name)?.[1];
97
+ if (header !== void 0 && isCredentialField(header, "header")) span.attribute(name, "[REDACTED]");
98
+ else if (typeof value === "string" && name === "url.query") span.attribute(name, redactUrlSecrets(`?${value}`).slice(1, 2049));
99
+ else if (typeof value === "string" && name === "url.path") {
100
+ const fullUrl = span.attributes.get("url.full");
101
+ const path = typeof fullUrl === "string" ? /^https?:\/\/[^/]+([^?#]*)/u.exec(fullUrl)?.[1] ?? value : value;
102
+ span.attribute(name, redactUrlSecrets(path, source).slice(0, 2048));
103
+ } else if (typeof value === "string" && (name === "url.full" || header === "location" || header === "referer" || header === "referrer")) span.attribute(name, redactUrlSecrets(value, source).slice(0, 2048));
104
+ else if (header !== void 0 && typeof value === "string") span.attribute(name, redactSecrets(value, source).slice(0, 2048));
105
+ else span.attribute(name, value);
106
+ } });
70
107
  Object.defineProperty(wrapped, "end", {
71
108
  configurable: false,
72
109
  enumerable: false,
73
- value: (endTime, exit) => span.end(endTime, Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause) ? REDACTED_SPAN_FAILURE : exit),
110
+ value: (endTime, exit) => {
111
+ if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) {
112
+ const first = exit.cause.reasons.find((reason) => !Cause.isInterruptReason(reason));
113
+ const code = failureText(first === void 0 ? void 0 : Cause.isFailReason(first) ? first.error : first.defect, "code", 128);
114
+ if (code !== void 0) span.attribute("error.code", code);
115
+ span.end(endTime, exportFailureExit(exit.cause, spanUpstream(span)));
116
+ } else span.end(endTime, exit);
117
+ },
74
118
  writable: false
75
119
  });
76
120
  return wrapped;
@@ -101,8 +145,29 @@ const makeEngineeringTelemetryLayer = (config) => {
101
145
  resource,
102
146
  mergeWithExisting: true
103
147
  });
104
- const headerPolicy = Layer.merge(Layer.succeed(HttpClient.TracerHeaderFilter, traceHeaderFilter), Layer.succeed(Headers.CurrentRedactedNames, ENGINEERING_REDACTED_HEADER_NAMES));
105
- return Layer.mergeAll(tracing, logging, headerPolicy).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer));
148
+ const headerPolicy = Layer.merge(Layer.succeed(HttpClient.TracerHeaderFilter, traceHeaderFilter), Layer.succeed(Headers.CurrentRedactedNames, [credentialHeaderPattern]));
149
+ const browserLifecycle = Layer.effectDiscard(Effect.gen(function* () {
150
+ if (validated.producer !== "browser" || !hasBrowserDocument()) return;
151
+ const browserWindow = window;
152
+ const browserDocument = document;
153
+ const flusher = yield* OtlpExporter.Flusher;
154
+ const run = yield* FiberSet.makeRuntime();
155
+ const flush = () => {
156
+ run(flusher.flush);
157
+ };
158
+ const visibilityChanged = () => {
159
+ if (browserDocument.visibilityState === "hidden") flush();
160
+ };
161
+ yield* Effect.acquireRelease(Effect.sync(() => {
162
+ browserDocument.addEventListener("visibilitychange", visibilityChanged);
163
+ browserWindow.addEventListener("pagehide", flush);
164
+ }), () => Effect.sync(() => {
165
+ browserDocument.removeEventListener("visibilitychange", visibilityChanged);
166
+ browserWindow.removeEventListener("pagehide", flush);
167
+ }));
168
+ }));
169
+ const transport = Layer.effect(HttpClient.HttpClient, Effect.map(HttpClient.HttpClient, (client) => validated.producer === "browser" && hasBrowserDocument() ? browserExportClient(client) : client)).pipe(Layer.provide(FetchHttpClient.layer));
170
+ return browserLifecycle.pipe(Layer.provideMerge(Layer.mergeAll(tracing, logging, headerPolicy)), Layer.provide(OtlpSerialization.layerJson), Layer.provide(transport));
106
171
  };
107
172
  //#endregion
108
173
  export { ENGINEERING_CAPXUL_ENVS, makeEngineeringTelemetryLayer, makeLeakSafeEngineeringTracer, postHogOtlpEndpoints, traceHeaderFilter };