@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 +22 -17
- package/dist/capxul-source.json +11 -0
- package/dist/engineering.d.mts +1 -1
- package/dist/engineering.mjs +90 -25
- package/dist/index.d.mts +418 -13
- package/dist/index.mjs +341 -101
- package/dist/src-CWfRYsBI.mjs +302 -0
- package/package.json +1 -1
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**,
|
|
36
|
-
| Exceptions | off | on
|
|
37
|
-
|
|
38
|
-
Both stamp `capxul_env` and `producer: "browser"` through `before_send
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
}
|
package/dist/engineering.d.mts
CHANGED
|
@@ -12,7 +12,7 @@ interface EngineeringTelemetryConfig {
|
|
|
12
12
|
readonly sdkVersion: string;
|
|
13
13
|
readonly serviceName?: string;
|
|
14
14
|
}
|
|
15
|
-
declare const traceHeaderFilter: (
|
|
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`;
|
package/dist/engineering.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
|
12
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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) =>
|
|
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,
|
|
105
|
-
|
|
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 };
|