@untangle-code/observability 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Profiling collector for `--profile` (architecture.md §24.3): stage
3
+ * timings via the injected `Clock`, file counts, cache hit rates, embedding
4
+ * and Qdrant latency/bytes, a parser/language breakdown, and the process
5
+ * memory high water mark.
6
+ *
7
+ * Stage timing uses `Clock` exclusively (BUILD_PLAN determinism rule).
8
+ * The memory high water mark is the one place this module reads real
9
+ * process state (`process.memoryUsage()`), because that is exactly what
10
+ * architecture.md §24.3 asks for — it is not a timing or randomness
11
+ * decision, just a resource-usage sample.
12
+ */
13
+ import type { Clock } from "@untangle-code/domain";
14
+
15
+ import { type HistogramSnapshot, summarizeHistogram } from "./metrics.js";
16
+
17
+ export interface StageTimingV1 {
18
+ stage: string;
19
+ startedAtIso: string;
20
+ endedAtIso: string;
21
+ durationMs: number;
22
+ }
23
+
24
+ export interface CacheHitRateV1 {
25
+ hits: number;
26
+ misses: number;
27
+ /** `hits / (hits + misses)`, or `null` when there were no accesses at all. */
28
+ rate: number | null;
29
+ }
30
+
31
+ export interface ParserBreakdownEntryV1 {
32
+ files: number;
33
+ failures: number;
34
+ }
35
+
36
+ export interface ProfileReportV1 {
37
+ stages: StageTimingV1[];
38
+ fileCounts: Record<string, number>;
39
+ cacheHitRates: Record<string, CacheHitRateV1>;
40
+ embeddingLatencyMs: HistogramSnapshot;
41
+ qdrantLatencyMs: HistogramSnapshot;
42
+ qdrantBytes: number;
43
+ parserBreakdown: Record<string, ParserBreakdownEntryV1>;
44
+ memoryHighWaterMarkBytes: number;
45
+ }
46
+
47
+ export interface StageHandle {
48
+ /** Ends the stage. Idempotent — a second call is a no-op. */
49
+ end(): void;
50
+ }
51
+
52
+ interface CacheCounters {
53
+ hits: number;
54
+ misses: number;
55
+ }
56
+
57
+ /** Reads the process's current resident set size. Isolated for testability. */
58
+ export function sampleMemoryBytes(): number {
59
+ return process.memoryUsage().rss;
60
+ }
61
+
62
+ /** Collects one `--profile` run's worth of measurements. */
63
+ export class ProfileCollector {
64
+ private readonly clock: Clock;
65
+ private readonly readMemoryBytes: () => number;
66
+ private readonly stages: StageTimingV1[] = [];
67
+ private readonly fileCounts: Record<string, number> = {};
68
+ private readonly cacheCounters: Record<string, CacheCounters> = {};
69
+ private readonly embeddingLatencies: number[] = [];
70
+ private readonly qdrantLatencies: number[] = [];
71
+ private readonly parserBreakdown: Record<string, ParserBreakdownEntryV1> = {};
72
+ private qdrantBytesTotal = 0;
73
+ private memoryHighWaterMarkBytes: number;
74
+
75
+ public constructor(deps: { clock: Clock; readMemoryBytes?: () => number }) {
76
+ this.clock = deps.clock;
77
+ this.readMemoryBytes = deps.readMemoryBytes ?? sampleMemoryBytes;
78
+ this.memoryHighWaterMarkBytes = this.readMemoryBytes();
79
+ }
80
+
81
+ /** Starts timing `stage`. Returns a handle whose `end()` records the duration. */
82
+ public beginStage(stage: string): StageHandle {
83
+ const startedAtIso = this.clock.nowIso();
84
+ const startedAtMs = this.clock.nowEpochMs();
85
+ this.sampleMemory();
86
+ let ended = false;
87
+ return {
88
+ end: (): void => {
89
+ if (ended) {
90
+ return;
91
+ }
92
+ ended = true;
93
+ const endedAtMs = this.clock.nowEpochMs();
94
+ this.stages.push({
95
+ stage,
96
+ startedAtIso,
97
+ endedAtIso: this.clock.nowIso(),
98
+ durationMs: endedAtMs - startedAtMs,
99
+ });
100
+ this.sampleMemory();
101
+ },
102
+ };
103
+ }
104
+
105
+ public recordFileCount(category: string, delta = 1): void {
106
+ this.fileCounts[category] = (this.fileCounts[category] ?? 0) + delta;
107
+ }
108
+
109
+ public recordCacheAccess(name: string, hit: boolean): void {
110
+ const counters = this.cacheCounters[name] ?? { hits: 0, misses: 0 };
111
+ if (hit) {
112
+ counters.hits += 1;
113
+ } else {
114
+ counters.misses += 1;
115
+ }
116
+ this.cacheCounters[name] = counters;
117
+ }
118
+
119
+ public recordEmbeddingLatency(ms: number): void {
120
+ this.embeddingLatencies.push(ms);
121
+ this.sampleMemory();
122
+ }
123
+
124
+ public recordQdrantLatency(ms: number): void {
125
+ this.qdrantLatencies.push(ms);
126
+ this.sampleMemory();
127
+ }
128
+
129
+ public addQdrantBytes(bytes: number): void {
130
+ this.qdrantBytesTotal += bytes;
131
+ }
132
+
133
+ public recordParserOutcome(language: string, outcome: "success" | "failure"): void {
134
+ const entry = this.parserBreakdown[language] ?? { files: 0, failures: 0 };
135
+ entry.files += 1;
136
+ if (outcome === "failure") {
137
+ entry.failures += 1;
138
+ }
139
+ this.parserBreakdown[language] = entry;
140
+ }
141
+
142
+ /** Re-samples the memory high water mark on demand (also sampled automatically around stages/latencies). */
143
+ public sampleMemory(): void {
144
+ const current = this.readMemoryBytes();
145
+ if (current > this.memoryHighWaterMarkBytes) {
146
+ this.memoryHighWaterMarkBytes = current;
147
+ }
148
+ }
149
+
150
+ public snapshot(): ProfileReportV1 {
151
+ const cacheHitRates: Record<string, CacheHitRateV1> = {};
152
+ for (const [name, counters] of Object.entries(this.cacheCounters)) {
153
+ const total = counters.hits + counters.misses;
154
+ cacheHitRates[name] = {
155
+ hits: counters.hits,
156
+ misses: counters.misses,
157
+ rate: total === 0 ? null : counters.hits / total,
158
+ };
159
+ }
160
+ return {
161
+ stages: [...this.stages],
162
+ fileCounts: { ...this.fileCounts },
163
+ cacheHitRates,
164
+ embeddingLatencyMs: summarizeHistogram(this.embeddingLatencies),
165
+ qdrantLatencyMs: summarizeHistogram(this.qdrantLatencies),
166
+ qdrantBytes: this.qdrantBytesTotal,
167
+ parserBreakdown: { ...this.parserBreakdown },
168
+ memoryHighWaterMarkBytes: this.memoryHighWaterMarkBytes,
169
+ };
170
+ }
171
+ }
package/src/redact.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Redaction (spec.md §24, §23.2): keeps secrets and full source text out of
3
+ * logs, status output and crash reports by default.
4
+ *
5
+ * `redact()` is a pure function — no I/O, no logging decisions of its own.
6
+ * The logger (`logger.ts`) applies it to every event's `context` before
7
+ * writing.
8
+ */
9
+
10
+ const MASK = "[REDACTED]";
11
+ const SOURCE_TEXT_SUPPRESSED = "[SOURCE_TEXT_SUPPRESSED]";
12
+
13
+ /**
14
+ * Key names spec.md §24 calls out by name, plus the common synonyms and
15
+ * variants ("api_key", "apiKey", "auth", …) adapters realistically use.
16
+ * Matched case-insensitively against the whole key.
17
+ */
18
+ const SECRET_KEY_PATTERN =
19
+ /(api[-_]?key|access[-_]?key|secret[-_]?key|private[-_]?key|token|password|passwd|pwd|secret|authorization|auth|cookie|credential)/i;
20
+
21
+ /**
22
+ * Field names that carry full source text / code (spec.md §23.2: "vollständige
23
+ * Quelltexte standardmäßig redigieren"). Mirrors the content-bearing fields of
24
+ * domain's `CodeChunkPayloadV1`/`CodeChunkDraft` plus generic synonyms, so a
25
+ * logged chunk, diff or search hit does not dump raw code into logs by
26
+ * default. Matched case-insensitively against the whole key.
27
+ */
28
+ const SOURCE_TEXT_KEYS = new Set([
29
+ "content",
30
+ "text",
31
+ "sourcetext",
32
+ "source",
33
+ "sourcecode",
34
+ "code",
35
+ "snippet",
36
+ "prepareddensetext",
37
+ "bm25text",
38
+ "diff",
39
+ "patch",
40
+ "raw",
41
+ "rawtext",
42
+ ]);
43
+
44
+ export interface RedactOptions {
45
+ /**
46
+ * Opt-in escape hatch for interactive debugging (spec.md §24: only on
47
+ * explicit debug opt-in). When `false` (default), values under a
48
+ * {@link SOURCE_TEXT_KEYS} key are replaced instead of logged verbatim.
49
+ */
50
+ includeSourceText?: boolean;
51
+ }
52
+
53
+ /**
54
+ * Recursively redacts `value`: keys that look like secrets are masked
55
+ * regardless of their value's shape, keys that carry source text are
56
+ * suppressed unless `includeSourceText` is set, and string values that look
57
+ * like a secret themselves (bearer tokens, JWTs, common vendor key prefixes)
58
+ * are masked even under an innocuous key name.
59
+ *
60
+ * Never throws: unsupported value kinds (bigint, function, symbol) are
61
+ * stringified rather than propagating a serialization error into a logging
62
+ * call.
63
+ */
64
+ export function redact(value: unknown, options: RedactOptions = {}): unknown {
65
+ return redactValue(value, options, new WeakSet<object>());
66
+ }
67
+
68
+ function redactValue(value: unknown, options: RedactOptions, seen: WeakSet<object>): unknown {
69
+ if (value === null || value === undefined) {
70
+ return value;
71
+ }
72
+ if (Array.isArray(value)) {
73
+ if (seen.has(value)) {
74
+ return "[CIRCULAR]";
75
+ }
76
+ seen.add(value);
77
+ return value.map((entry) => redactValue(entry, options, seen));
78
+ }
79
+ if (value instanceof Date) {
80
+ return value.toISOString();
81
+ }
82
+ if (typeof value === "object") {
83
+ if (seen.has(value)) {
84
+ return "[CIRCULAR]";
85
+ }
86
+ seen.add(value);
87
+ const out: Record<string, unknown> = {};
88
+ for (const [key, entryValue] of Object.entries(value as Record<string, unknown>)) {
89
+ out[key] = redactEntry(key, entryValue, options, seen);
90
+ }
91
+ return out;
92
+ }
93
+ if (typeof value === "bigint") {
94
+ return value.toString();
95
+ }
96
+ if (typeof value === "function" || typeof value === "symbol") {
97
+ return `[${typeof value}]`;
98
+ }
99
+ if (typeof value === "string" && looksLikeSecretValue(value)) {
100
+ return MASK;
101
+ }
102
+ return value;
103
+ }
104
+
105
+ function redactEntry(
106
+ key: string,
107
+ entryValue: unknown,
108
+ options: RedactOptions,
109
+ seen: WeakSet<object>,
110
+ ): unknown {
111
+ const lowerKey = key.toLowerCase();
112
+ if (SECRET_KEY_PATTERN.test(lowerKey)) {
113
+ return MASK;
114
+ }
115
+ if (!options.includeSourceText && SOURCE_TEXT_KEYS.has(lowerKey)) {
116
+ return SOURCE_TEXT_SUPPRESSED;
117
+ }
118
+ return redactValue(entryValue, options, seen);
119
+ }
120
+
121
+ /**
122
+ * Heuristic for "a value that looks like a key" (spec.md §24): bearer
123
+ * headers, JWT-shaped triples, and common vendor secret prefixes
124
+ * (`sk-`, `ghp_`, `xox…`). Conservative on purpose — false negatives are
125
+ * expected, false positives on ordinary prose are not.
126
+ */
127
+ function looksLikeSecretValue(value: string): boolean {
128
+ if (/^bearer\s+\S+/i.test(value)) {
129
+ return true;
130
+ }
131
+ if (/^[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}$/.test(value)) {
132
+ return true;
133
+ }
134
+ if (/^(sk|pk|rk)-[A-Za-z0-9_-]{16,}$/i.test(value)) {
135
+ return true;
136
+ }
137
+ if (/^(ghp|gho|ghu|ghs|github_pat)_[A-Za-z0-9_]{16,}$/i.test(value)) {
138
+ return true;
139
+ }
140
+ if (/^xox[baprs]-[A-Za-z0-9-]{10,}$/i.test(value)) {
141
+ return true;
142
+ }
143
+ return false;
144
+ }