@untangle-code/observability 0.0.0-stage → 0.1.0
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/LICENSE +661 -0
- package/NOTICE +90 -0
- package/README.md +114 -2
- package/THIRD_PARTY_LICENSES.md +24 -0
- package/dist/correlation.d.ts +36 -0
- package/dist/correlation.d.ts.map +1 -0
- package/dist/correlation.js +12 -0
- package/dist/correlation.js.map +1 -0
- package/dist/errors.d.ts +74 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +100 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +52 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +108 -0
- package/dist/logger.js.map +1 -0
- package/dist/metrics.d.ts +62 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +121 -0
- package/dist/metrics.js.map +1 -0
- package/dist/profiling.d.ts +75 -0
- package/dist/profiling.d.ts.map +1 -0
- package/dist/profiling.js +107 -0
- package/dist/profiling.js.map +1 -0
- package/dist/redact.d.ts +29 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +123 -0
- package/dist/redact.js.map +1 -0
- package/package.json +59 -3
- package/src/correlation.ts +44 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +26 -0
- package/src/logger.ts +192 -0
- package/src/metrics.ts +157 -0
- package/src/profiling.ts +171 -0
- package/src/redact.ts +144 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redact.js","sourceRoot":"","sources":["../src/redact.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,MAAM,IAAI,GAAG,YAAY,CAAC;AAC1B,MAAM,sBAAsB,GAAG,0BAA0B,CAAC;AAE1D;;;;GAIG;AACH,MAAM,kBAAkB,GACtB,oIAAoI,CAAC;AAEvI;;;;;;GAMG;AACH,MAAM,gBAAgB,GAAG,IAAI,GAAG,CAAC;IAC/B,SAAS;IACT,MAAM;IACN,YAAY;IACZ,QAAQ;IACR,YAAY;IACZ,MAAM;IACN,SAAS;IACT,mBAAmB;IACnB,UAAU;IACV,MAAM;IACN,OAAO;IACP,KAAK;IACL,SAAS;CACV,CAAC,CAAC;AAWH;;;;;;;;;;GAUG;AACH,MAAM,UAAU,MAAM,CAAC,KAAc,EAAE,UAAyB,EAAE;IAChE,OAAO,WAAW,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,OAAO,EAAU,CAAC,CAAC;AAC5D,CAAC;AAED,SAAS,WAAW,CAAC,KAAc,EAAE,OAAsB,EAAE,IAAqB;IAChF,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QAC1C,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACzB,IAAI,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;YACpB,OAAO,YAAY,CAAC;QACtB,CAAC;QACD,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QAChB,OAAO,KAAK,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,WAAW,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC;IACjE,CAAC;IACD,IAAI,KAAK,YAAY,IAAI,EAAE,CAAC;QAC1B,OAAO,KAAK,CAAC,WAAW,EAAE,CAAC;IAC7B,CAAC;IACD,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC9B,IAAI,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;YACpB,OAAO,YAAY,CAAC;QACtB,CAAC;QACD,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QAChB,MAAM,GAAG,GAA4B,EAAE,CAAC;QACxC,KAAK,MAAM,CAAC,GAAG,EAAE,UAAU,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAgC,CAAC,EAAE,CAAC;YACjF,GAAG,CAAC,GAAG,CAAC,GAAG,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,OAAO,EAAE,IAAI,CAAC,CAAC;QACzD,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC;IACD,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC9B,OAAO,KAAK,CAAC,QAAQ,EAAE,CAAC;IAC1B,CAAC;IACD,IAAI,OAAO,KAAK,KAAK,UAAU,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC7D,OAAO,IAAI,OAAO,KAAK,GAAG,CAAC;IAC7B,CAAC;IACD,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,oBAAoB,CAAC,KAAK,CAAC,EAAE,CAAC;QAC7D,OAAO,IAAI,CAAC;IACd,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,WAAW,CAClB,GAAW,EACX,UAAmB,EACnB,OAAsB,EACtB,IAAqB;IAErB,MAAM,QAAQ,GAAG,GAAG,CAAC,WAAW,EAAE,CAAC;IACnC,IAAI,kBAAkB,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;QACtC,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,CAAC,OAAO,CAAC,iBAAiB,IAAI,gBAAgB,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC;QACjE,OAAO,sBAAsB,CAAC;IAChC,CAAC;IACD,OAAO,WAAW,CAAC,UAAU,EAAE,OAAO,EAAE,IAAI,CAAC,CAAC;AAChD,CAAC;AAED;;;;;GAKG;AACH,SAAS,oBAAoB,CAAC,KAAa;IACzC,IAAI,gBAAgB,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QACjC,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,8DAA8D,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QAC/E,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,kCAAkC,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QACnD,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,mDAAmD,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QACpE,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,iCAAiC,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QAClD,OAAO,IAAI,CAAC;IACd,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@untangle-code/observability",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Untangle observability: typed errors, structured logging, redaction, correlation ids, metrics and profiling. No I/O ports beyond stderr/process.",
|
|
7
|
+
"license": "AGPL-3.0-only",
|
|
8
|
+
"homepage": "https://github.com/lboel/untangle",
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/lboel/untangle/issues"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/lboel/untangle.git",
|
|
15
|
+
"directory": "packages/observability"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": "^22.13.0 || >=24.0.0"
|
|
22
|
+
},
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"!dist/**/*.test.*",
|
|
32
|
+
"!dist/**/test-helpers.*",
|
|
33
|
+
"!dist/**/test-support.*",
|
|
34
|
+
"!dist/**/test-support/**",
|
|
35
|
+
"!dist/**/testing/**",
|
|
36
|
+
"!dist/**/__tests__/**",
|
|
37
|
+
"src",
|
|
38
|
+
"!src/**/*.test.ts",
|
|
39
|
+
"!src/**/test-helpers.ts",
|
|
40
|
+
"!src/**/test-support.ts",
|
|
41
|
+
"!src/**/test-support/**",
|
|
42
|
+
"!src/**/testing/**",
|
|
43
|
+
"LICENSE",
|
|
44
|
+
"NOTICE",
|
|
45
|
+
"README.md",
|
|
46
|
+
"THIRD_PARTY_LICENSES.md"
|
|
47
|
+
],
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@untangle-code/domain": "0.1.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "22.19.7",
|
|
53
|
+
"typescript": "5.9.3",
|
|
54
|
+
"vitest": "3.2.7"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "node ../../scripts/tsc-build.mjs tsconfig.json",
|
|
58
|
+
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
59
|
+
"test": "vitest run",
|
|
60
|
+
"clean": "rm -rf dist *.tsbuildinfo"
|
|
61
|
+
}
|
|
6
62
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Correlation ids (architecture.md §24.1).
|
|
3
|
+
*
|
|
4
|
+
* Ids are drawn exclusively from the injected `Clock`/`Random` ports so that
|
|
5
|
+
* request contexts are reproducible in tests — never `Date.now()` or
|
|
6
|
+
* `Math.random()` (BUILD_PLAN global determinism rule).
|
|
7
|
+
*
|
|
8
|
+
* `request_id` is the only id created here. `job_id` and `generation_id`
|
|
9
|
+
* are owned by the subsystems that create the underlying job/generation
|
|
10
|
+
* (job-engine, index pipeline) — they mint them the same way, via
|
|
11
|
+
* `Random.uuid()`, once those packages exist; minting them here would just
|
|
12
|
+
* move the decision of *when* a job/generation begins out of its owner.
|
|
13
|
+
* `workspace_id`/`repo_id`/`ref_id` are never random — they are the
|
|
14
|
+
* content-derived identities `@untangle-code/domain` already builds
|
|
15
|
+
* (`buildWorkspaceId` et al.) and are attached to a request context by the
|
|
16
|
+
* caller, not generated by observability.
|
|
17
|
+
*/
|
|
18
|
+
import type { Clock, Random } from "@untangle-code/domain";
|
|
19
|
+
|
|
20
|
+
export interface RequestContextDeps {
|
|
21
|
+
clock: Clock;
|
|
22
|
+
random: Random;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RequestContext {
|
|
26
|
+
/** Random per-operation id (architecture.md §24.1: `request_id`). */
|
|
27
|
+
requestId: string;
|
|
28
|
+
/** Epoch-ms creation time, via `Clock` — never `Date.now()`. */
|
|
29
|
+
createdAtEpochMs: number;
|
|
30
|
+
/** ISO-8601 creation time, via `Clock`. */
|
|
31
|
+
createdAtIso: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Creates a fresh request context. Deterministic given a deterministic
|
|
36
|
+
* `Clock`/`Random` (e.g. in tests): same fakes in, same ids out.
|
|
37
|
+
*/
|
|
38
|
+
export function createRequestContext(deps: RequestContextDeps): RequestContext {
|
|
39
|
+
return {
|
|
40
|
+
requestId: deps.random.uuid(),
|
|
41
|
+
createdAtEpochMs: deps.clock.nowEpochMs(),
|
|
42
|
+
createdAtIso: deps.clock.nowIso(),
|
|
43
|
+
};
|
|
44
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed error helpers on top of `@untangle-code/domain`'s `UntangleError`
|
|
3
|
+
* (architecture.md §22.1, spec.md §25.1, portfolio.md §15).
|
|
4
|
+
*
|
|
5
|
+
* `UntangleError`, `ErrorCode`, `ERROR_CODES` and `isUntangleError` are
|
|
6
|
+
* already fully defined in domain (BUILD_PLAN §3 contract register) — this
|
|
7
|
+
* module does not redefine them, only adds:
|
|
8
|
+
*
|
|
9
|
+
* - one factory function per error class (`ERROR_FACTORIES`, plus a named
|
|
10
|
+
* export per code for ergonomic call sites), and
|
|
11
|
+
* - `toErrorEnvelope()`, which turns *any* thrown value into the `error`
|
|
12
|
+
* field shape of `ToolResponseEnvelopeV1` (spec.md §21.2), including
|
|
13
|
+
* values that are not `UntangleError` at all (adapter throws, programmer
|
|
14
|
+
* errors) so that MCP/CLI response building never has to branch on the
|
|
15
|
+
* error's runtime type itself.
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
ERROR_CODES,
|
|
19
|
+
UntangleError,
|
|
20
|
+
isUntangleError,
|
|
21
|
+
type ErrorCode,
|
|
22
|
+
type ToolResponseEnvelopeV1,
|
|
23
|
+
type UntangleErrorOptions,
|
|
24
|
+
} from "@untangle-code/domain";
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
ERROR_CODES,
|
|
28
|
+
CORE_ERROR_CODES,
|
|
29
|
+
PORTFOLIO_ERROR_CODES,
|
|
30
|
+
STORAGE_ERROR_CODES,
|
|
31
|
+
isErrorCode,
|
|
32
|
+
isUntangleError,
|
|
33
|
+
UntangleError,
|
|
34
|
+
} from "@untangle-code/domain";
|
|
35
|
+
export type { ErrorCode, UntangleErrorOptions } from "@untangle-code/domain";
|
|
36
|
+
|
|
37
|
+
/** Envelope `error` field shape (spec.md §21.2), derived from domain's envelope type. */
|
|
38
|
+
export type EnvelopeErrorV1 = NonNullable<ToolResponseEnvelopeV1<unknown>["error"]>;
|
|
39
|
+
|
|
40
|
+
/** Code used for `toErrorEnvelope()` when the input is not an `UntangleError`. */
|
|
41
|
+
export const UNKNOWN_ERROR_CODE = "UNKNOWN_ERROR";
|
|
42
|
+
|
|
43
|
+
export type ErrorFactory = (message: string, options?: UntangleErrorOptions) => UntangleError;
|
|
44
|
+
|
|
45
|
+
function factory(code: ErrorCode): ErrorFactory {
|
|
46
|
+
return (message: string, options?: UntangleErrorOptions): UntangleError =>
|
|
47
|
+
new UntangleError(code, message, options);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One factory per {@link ErrorCode} (spec.md §25.1 ∪ portfolio.md §15).
|
|
52
|
+
*
|
|
53
|
+
* Built from `ERROR_CODES` itself so this map can never drift out of sync
|
|
54
|
+
* with domain's code list — see `errors.test.ts` for the completeness check.
|
|
55
|
+
*/
|
|
56
|
+
export const ERROR_FACTORIES: Readonly<Record<ErrorCode, ErrorFactory>> = Object.freeze(
|
|
57
|
+
Object.fromEntries(ERROR_CODES.map((code) => [code, factory(code)])) as Record<
|
|
58
|
+
ErrorCode,
|
|
59
|
+
ErrorFactory
|
|
60
|
+
>,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Named convenience exports — one per error class (spec.md §25.1).
|
|
64
|
+
export const configInvalidError = ERROR_FACTORIES.CONFIG_INVALID;
|
|
65
|
+
export const workspaceNotFoundError = ERROR_FACTORIES.WORKSPACE_NOT_FOUND;
|
|
66
|
+
export const pathOutsideWorkspaceError = ERROR_FACTORIES.PATH_OUTSIDE_WORKSPACE;
|
|
67
|
+
export const parserUnavailableError = ERROR_FACTORIES.PARSER_UNAVAILABLE;
|
|
68
|
+
export const embeddingUnavailableError = ERROR_FACTORIES.EMBEDDING_UNAVAILABLE;
|
|
69
|
+
export const embeddingDimensionMismatchError = ERROR_FACTORIES.EMBEDDING_DIMENSION_MISMATCH;
|
|
70
|
+
export const qdrantUnavailableError = ERROR_FACTORIES.QDRANT_UNAVAILABLE;
|
|
71
|
+
export const qdrantSchemaIncompatibleError = ERROR_FACTORIES.QDRANT_SCHEMA_INCOMPATIBLE;
|
|
72
|
+
export const lanceDbUnavailableError = ERROR_FACTORIES.LANCEDB_UNAVAILABLE;
|
|
73
|
+
export const lanceDbSchemaIncompatibleError = ERROR_FACTORIES.LANCEDB_SCHEMA_INCOMPATIBLE;
|
|
74
|
+
export const indexIncompleteError = ERROR_FACTORIES.INDEX_INCOMPLETE;
|
|
75
|
+
export const indexProfileMismatchError = ERROR_FACTORIES.INDEX_PROFILE_MISMATCH;
|
|
76
|
+
export const lockHeldError = ERROR_FACTORIES.LOCK_HELD;
|
|
77
|
+
export const jobCancelledError = ERROR_FACTORIES.JOB_CANCELLED;
|
|
78
|
+
export const graphIncompleteError = ERROR_FACTORIES.GRAPH_INCOMPLETE;
|
|
79
|
+
export const analysisIncompleteError = ERROR_FACTORIES.ANALYSIS_INCOMPLETE;
|
|
80
|
+
export const pointTooLargeError = ERROR_FACTORIES.POINT_TOO_LARGE;
|
|
81
|
+
export const pluginEngineIncompatibleError = ERROR_FACTORIES.PLUGIN_ENGINE_INCOMPATIBLE;
|
|
82
|
+
|
|
83
|
+
// Named convenience exports — one per error class (portfolio.md §15).
|
|
84
|
+
export const portfolioIncompleteError = ERROR_FACTORIES.PORTFOLIO_INCOMPLETE;
|
|
85
|
+
export const portfolioGenerationMissingError = ERROR_FACTORIES.PORTFOLIO_GENERATION_MISSING;
|
|
86
|
+
export const portfolioStaleError = ERROR_FACTORIES.PORTFOLIO_STALE;
|
|
87
|
+
export const manifestParseFailedError = ERROR_FACTORIES.MANIFEST_PARSE_FAILED;
|
|
88
|
+
export const technologyUnknownError = ERROR_FACTORIES.TECHNOLOGY_UNKNOWN;
|
|
89
|
+
export const blueprintKindUnknownError = ERROR_FACTORIES.BLUEPRINT_KIND_UNKNOWN;
|
|
90
|
+
export const blueprintEvidenceInsufficientError = ERROR_FACTORIES.BLUEPRINT_EVIDENCE_INSUFFICIENT;
|
|
91
|
+
export const blueprintTargetNotEmptyError = ERROR_FACTORIES.BLUEPRINT_TARGET_NOT_EMPTY;
|
|
92
|
+
export const blueprintTargetOutsideAllowedError = ERROR_FACTORIES.BLUEPRINT_TARGET_OUTSIDE_ALLOWED;
|
|
93
|
+
export const blueprintWriteFailedError = ERROR_FACTORIES.BLUEPRINT_WRITE_FAILED;
|
|
94
|
+
export const enrichmentDisabledError = ERROR_FACTORIES.ENRICHMENT_DISABLED;
|
|
95
|
+
export const enrichmentUnavailableError = ERROR_FACTORIES.ENRICHMENT_UNAVAILABLE;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Turns any thrown value into the `error` field of `ToolResponseEnvelopeV1`
|
|
99
|
+
* (spec.md §21.2).
|
|
100
|
+
*
|
|
101
|
+
* - An `UntangleError` round-trips through its own `toEnvelopeError()`.
|
|
102
|
+
* - A plain `Error` (adapter throw, programmer error) is mapped to
|
|
103
|
+
* {@link UNKNOWN_ERROR_CODE} with `retryable: false` — never silently
|
|
104
|
+
* promoted to a known {@link ErrorCode}, since "not found" and
|
|
105
|
+
* "infrastructure failure" must never be conflated (spec.md §25.2).
|
|
106
|
+
* - Any other thrown value is stringified the same way.
|
|
107
|
+
*/
|
|
108
|
+
export function toErrorEnvelope(error: unknown): EnvelopeErrorV1 {
|
|
109
|
+
if (isUntangleError(error)) {
|
|
110
|
+
return error.toEnvelopeError();
|
|
111
|
+
}
|
|
112
|
+
if (error instanceof Error) {
|
|
113
|
+
return {
|
|
114
|
+
code: UNKNOWN_ERROR_CODE,
|
|
115
|
+
message: error.message,
|
|
116
|
+
retryable: false,
|
|
117
|
+
details: { name: error.name },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
code: UNKNOWN_ERROR_CODE,
|
|
122
|
+
message: typeof error === "string" ? error : safeStringify(error),
|
|
123
|
+
retryable: false,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function safeStringify(value: unknown): string {
|
|
128
|
+
try {
|
|
129
|
+
return JSON.stringify(value) ?? String(value);
|
|
130
|
+
} catch {
|
|
131
|
+
return String(value);
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @untangle-code/observability — the only public export of this package.
|
|
3
|
+
*
|
|
4
|
+
* Errors (architecture.md §22.1), structured logging (§24.2), redaction
|
|
5
|
+
* (spec.md §24), correlation ids (§24.1), metrics (spec.md §23.3) and
|
|
6
|
+
* `--profile` collection (architecture.md §24.3). No I/O beyond
|
|
7
|
+
* `process.stderr`/`process.memoryUsage`; no network, no filesystem.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Errors — factories on top of domain's UntangleError, plus envelope mapping.
|
|
11
|
+
export * from "./errors.js";
|
|
12
|
+
|
|
13
|
+
// Redaction.
|
|
14
|
+
export * from "./redact.js";
|
|
15
|
+
|
|
16
|
+
// Correlation ids.
|
|
17
|
+
export * from "./correlation.js";
|
|
18
|
+
|
|
19
|
+
// Structured logger (LoggerPort implementation).
|
|
20
|
+
export * from "./logger.js";
|
|
21
|
+
|
|
22
|
+
// Metrics registry (spec.md §23.3).
|
|
23
|
+
export * from "./metrics.js";
|
|
24
|
+
|
|
25
|
+
// Profiling collector (architecture.md §24.3).
|
|
26
|
+
export * from "./profiling.js";
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured logger (architecture.md §24.2) implementing domain's
|
|
3
|
+
* `LoggerPort`: JSON lines on stderr, level filtering, and a child logger
|
|
4
|
+
* with bound context.
|
|
5
|
+
*
|
|
6
|
+
* `LogEvent` (architecture.md §24.2, fixed in domain) only carries
|
|
7
|
+
* `requestId`/`jobId` as first-class correlation fields; `generationId`,
|
|
8
|
+
* `workspaceId`, `repoId` and `refId` (architecture.md §24.1's full
|
|
9
|
+
* correlation set) have no first-class slot in that shape. This module's
|
|
10
|
+
* decision (README "Entscheidungen"): those four travel inside `context`
|
|
11
|
+
* under their own keys, merged in the same way `context` itself is —
|
|
12
|
+
* closest (call-site) binding wins over an ancestor `child()`'s binding.
|
|
13
|
+
*/
|
|
14
|
+
import type { Clock, LogEvent, LoggerPort, LogLevel } from "@untangle-code/domain";
|
|
15
|
+
|
|
16
|
+
import { redact } from "./redact.js";
|
|
17
|
+
|
|
18
|
+
const LEVEL_ORDER: Record<LogLevel, number> = {
|
|
19
|
+
trace: 10,
|
|
20
|
+
debug: 20,
|
|
21
|
+
info: 30,
|
|
22
|
+
warn: 40,
|
|
23
|
+
error: 50,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Bindings a child logger merges into every event it emits. */
|
|
27
|
+
export interface LoggerBindings {
|
|
28
|
+
component?: string;
|
|
29
|
+
requestId?: string;
|
|
30
|
+
jobId?: string;
|
|
31
|
+
generationId?: string;
|
|
32
|
+
workspaceId?: string;
|
|
33
|
+
repoId?: string;
|
|
34
|
+
refId?: string;
|
|
35
|
+
context?: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `LoggerPort` plus per-level convenience methods that fill in `timestamp` via `Clock`. */
|
|
39
|
+
export interface Logger extends LoggerPort {
|
|
40
|
+
error(event: string, context?: Record<string, unknown>): void;
|
|
41
|
+
warn(event: string, context?: Record<string, unknown>): void;
|
|
42
|
+
info(event: string, context?: Record<string, unknown>): void;
|
|
43
|
+
debug(event: string, context?: Record<string, unknown>): void;
|
|
44
|
+
trace(event: string, context?: Record<string, unknown>): void;
|
|
45
|
+
child(bindings: LoggerBindings): Logger;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CreateLoggerOptions {
|
|
49
|
+
/** Time source for auto-timestamped convenience methods (`info()`, …). Never `Date.now()`. */
|
|
50
|
+
clock: Clock;
|
|
51
|
+
/** Minimum level emitted; events below it are dropped without touching `sink`. Default `"info"`. */
|
|
52
|
+
level?: LogLevel;
|
|
53
|
+
/** Where a finished JSON line is written. Default: one `process.stderr.write` call per event. */
|
|
54
|
+
sink?: (line: string) => void;
|
|
55
|
+
/**
|
|
56
|
+
* Opt-in escape hatch (spec.md §24): when `false` (default), source-text
|
|
57
|
+
* fields in `context` are suppressed rather than logged verbatim.
|
|
58
|
+
*/
|
|
59
|
+
debugIncludeSourceText?: boolean;
|
|
60
|
+
/** Initial bound context, equivalent to calling `.child(bindings)` once up front. */
|
|
61
|
+
bindings?: LoggerBindings;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function defaultSink(line: string): void {
|
|
65
|
+
process.stderr.write(`${line}\n`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function mergeBindings(base: LoggerBindings, next: LoggerBindings): LoggerBindings {
|
|
69
|
+
const merged: LoggerBindings = { ...base, ...next };
|
|
70
|
+
const context = { ...base.context, ...next.context };
|
|
71
|
+
if (Object.keys(context).length > 0) {
|
|
72
|
+
merged.context = context;
|
|
73
|
+
} else {
|
|
74
|
+
delete merged.context;
|
|
75
|
+
}
|
|
76
|
+
return merged;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Correlation keys promoted from {@link LoggerBindings} into `context` (see module doc). */
|
|
80
|
+
function correlationContext(bindings: LoggerBindings): Record<string, unknown> {
|
|
81
|
+
const extra: Record<string, unknown> = {};
|
|
82
|
+
if (bindings.generationId !== undefined) extra["generationId"] = bindings.generationId;
|
|
83
|
+
if (bindings.workspaceId !== undefined) extra["workspaceId"] = bindings.workspaceId;
|
|
84
|
+
if (bindings.repoId !== undefined) extra["repoId"] = bindings.repoId;
|
|
85
|
+
if (bindings.refId !== undefined) extra["refId"] = bindings.refId;
|
|
86
|
+
return extra;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
class JsonLinesLogger implements Logger {
|
|
90
|
+
private readonly clock: Clock;
|
|
91
|
+
private readonly minLevel: LogLevel;
|
|
92
|
+
private readonly sink: (line: string) => void;
|
|
93
|
+
private readonly includeSourceText: boolean;
|
|
94
|
+
private readonly bindings: LoggerBindings;
|
|
95
|
+
|
|
96
|
+
public constructor(
|
|
97
|
+
clock: Clock,
|
|
98
|
+
minLevel: LogLevel,
|
|
99
|
+
sink: (line: string) => void,
|
|
100
|
+
includeSourceText: boolean,
|
|
101
|
+
bindings: LoggerBindings,
|
|
102
|
+
) {
|
|
103
|
+
this.clock = clock;
|
|
104
|
+
this.minLevel = minLevel;
|
|
105
|
+
this.sink = sink;
|
|
106
|
+
this.includeSourceText = includeSourceText;
|
|
107
|
+
this.bindings = bindings;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
public log(event: LogEvent): void {
|
|
111
|
+
if (LEVEL_ORDER[event.level] < LEVEL_ORDER[this.minLevel]) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const component = event.component || this.bindings.component || "untangle";
|
|
115
|
+
const requestId = event.requestId ?? this.bindings.requestId;
|
|
116
|
+
const jobId = event.jobId ?? this.bindings.jobId;
|
|
117
|
+
const context = {
|
|
118
|
+
...correlationContext(this.bindings),
|
|
119
|
+
...this.bindings.context,
|
|
120
|
+
...event.context,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const line: LogEvent = {
|
|
124
|
+
timestamp: event.timestamp,
|
|
125
|
+
level: event.level,
|
|
126
|
+
component,
|
|
127
|
+
event: event.event,
|
|
128
|
+
...(requestId !== undefined ? { requestId } : {}),
|
|
129
|
+
...(jobId !== undefined ? { jobId } : {}),
|
|
130
|
+
...(Object.keys(context).length > 0
|
|
131
|
+
? {
|
|
132
|
+
context: redact(context, { includeSourceText: this.includeSourceText }) as Record<
|
|
133
|
+
string,
|
|
134
|
+
unknown
|
|
135
|
+
>,
|
|
136
|
+
}
|
|
137
|
+
: {}),
|
|
138
|
+
};
|
|
139
|
+
this.sink(JSON.stringify(line));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
public child(bindings: LoggerBindings): Logger {
|
|
143
|
+
return new JsonLinesLogger(
|
|
144
|
+
this.clock,
|
|
145
|
+
this.minLevel,
|
|
146
|
+
this.sink,
|
|
147
|
+
this.includeSourceText,
|
|
148
|
+
mergeBindings(this.bindings, bindings),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
public error(event: string, context?: Record<string, unknown>): void {
|
|
153
|
+
this.emit("error", event, context);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
public warn(event: string, context?: Record<string, unknown>): void {
|
|
157
|
+
this.emit("warn", event, context);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
public info(event: string, context?: Record<string, unknown>): void {
|
|
161
|
+
this.emit("info", event, context);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
public debug(event: string, context?: Record<string, unknown>): void {
|
|
165
|
+
this.emit("debug", event, context);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
public trace(event: string, context?: Record<string, unknown>): void {
|
|
169
|
+
this.emit("trace", event, context);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private emit(level: LogLevel, event: string, context: Record<string, unknown> | undefined): void {
|
|
173
|
+
this.log({
|
|
174
|
+
timestamp: this.clock.nowIso(),
|
|
175
|
+
level,
|
|
176
|
+
component: this.bindings.component ?? "untangle",
|
|
177
|
+
event,
|
|
178
|
+
...(context !== undefined ? { context } : {}),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Creates a structured, JSON-lines-on-stderr {@link Logger} (architecture.md §24.2). */
|
|
184
|
+
export function createLogger(options: CreateLoggerOptions): Logger {
|
|
185
|
+
return new JsonLinesLogger(
|
|
186
|
+
options.clock,
|
|
187
|
+
options.level ?? "info",
|
|
188
|
+
options.sink ?? defaultSink,
|
|
189
|
+
options.debugIncludeSourceText ?? false,
|
|
190
|
+
options.bindings ?? {},
|
|
191
|
+
);
|
|
192
|
+
}
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metrics registry (spec.md §23.3): exactly the internal counters the core
|
|
3
|
+
* engine SHOULD expose. No external exporter — `snapshot()` returns a plain
|
|
4
|
+
* object, which callers may print, log, or serve however they like.
|
|
5
|
+
*
|
|
6
|
+
* spec.md §23.3 calls all eleven names "Zähler" (counters), but three of
|
|
7
|
+
* them are not cumulative counts by nature:
|
|
8
|
+
* - `search_latency_ms` is a distribution of per-search durations, not a
|
|
9
|
+
* single number — modeled as a histogram.
|
|
10
|
+
* - `resolved_import_ratio`/`resolved_call_ratio` are point-in-time ratios
|
|
11
|
+
* (resolved / total at last computation), which a monotonic counter
|
|
12
|
+
* cannot represent without lying about "cumulative" — modeled as gauges
|
|
13
|
+
* the caller sets to the latest computed value.
|
|
14
|
+
*
|
|
15
|
+
* The other eight are genuinely cumulative and are modeled as counters.
|
|
16
|
+
* This is a WP-04-local decision (README "Entscheidungen") — the set of
|
|
17
|
+
* names is exactly spec.md §23.3's eleven, not open for extension by name.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Cumulative counters (spec.md §23.3). */
|
|
21
|
+
export const COUNTER_NAMES = [
|
|
22
|
+
"files_scanned",
|
|
23
|
+
"files_changed",
|
|
24
|
+
"chunks_created",
|
|
25
|
+
"embeddings_requested",
|
|
26
|
+
"embedding_cache_hits",
|
|
27
|
+
"qdrant_upsert_bytes",
|
|
28
|
+
"parse_failures",
|
|
29
|
+
"watcher_reconciliations",
|
|
30
|
+
] as const;
|
|
31
|
+
export type CounterName = (typeof COUNTER_NAMES)[number];
|
|
32
|
+
|
|
33
|
+
/** Latency distributions (spec.md §23.3). */
|
|
34
|
+
export const HISTOGRAM_NAMES = ["search_latency_ms"] as const;
|
|
35
|
+
export type HistogramName = (typeof HISTOGRAM_NAMES)[number];
|
|
36
|
+
|
|
37
|
+
/** Point-in-time ratios (spec.md §23.3). */
|
|
38
|
+
export const RATIO_NAMES = ["resolved_import_ratio", "resolved_call_ratio"] as const;
|
|
39
|
+
export type RatioName = (typeof RATIO_NAMES)[number];
|
|
40
|
+
|
|
41
|
+
export interface HistogramSnapshot {
|
|
42
|
+
count: number;
|
|
43
|
+
sum: number;
|
|
44
|
+
min: number;
|
|
45
|
+
max: number;
|
|
46
|
+
avg: number;
|
|
47
|
+
p50: number;
|
|
48
|
+
p95: number;
|
|
49
|
+
p99: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface MetricsSnapshotV1 {
|
|
53
|
+
counters: Record<CounterName, number>;
|
|
54
|
+
histograms: Record<HistogramName, HistogramSnapshot>;
|
|
55
|
+
ratios: Record<RatioName, number | null>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const EMPTY_HISTOGRAM: HistogramSnapshot = {
|
|
59
|
+
count: 0,
|
|
60
|
+
sum: 0,
|
|
61
|
+
min: 0,
|
|
62
|
+
max: 0,
|
|
63
|
+
avg: 0,
|
|
64
|
+
p50: 0,
|
|
65
|
+
p95: 0,
|
|
66
|
+
p99: 0,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Summarizes raw samples into a {@link HistogramSnapshot}. Pure, no state. */
|
|
70
|
+
export function summarizeHistogram(samples: readonly number[]): HistogramSnapshot {
|
|
71
|
+
if (samples.length === 0) {
|
|
72
|
+
return EMPTY_HISTOGRAM;
|
|
73
|
+
}
|
|
74
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
75
|
+
const sum = sorted.reduce((total, value) => total + value, 0);
|
|
76
|
+
return {
|
|
77
|
+
count: sorted.length,
|
|
78
|
+
sum,
|
|
79
|
+
min: sorted[0]!,
|
|
80
|
+
max: sorted[sorted.length - 1]!,
|
|
81
|
+
avg: sum / sorted.length,
|
|
82
|
+
p50: percentile(sorted, 0.5),
|
|
83
|
+
p95: percentile(sorted, 0.95),
|
|
84
|
+
p99: percentile(sorted, 0.99),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function percentile(sorted: readonly number[], fraction: number): number {
|
|
89
|
+
const index = Math.min(sorted.length - 1, Math.floor(fraction * sorted.length));
|
|
90
|
+
return sorted[index]!;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** In-process registry for exactly the eleven counters of spec.md §23.3. */
|
|
94
|
+
export class MetricsRegistry {
|
|
95
|
+
private readonly counters: Record<CounterName, number>;
|
|
96
|
+
private readonly samples: Record<HistogramName, number[]>;
|
|
97
|
+
private readonly ratios: Record<RatioName, number | null>;
|
|
98
|
+
|
|
99
|
+
public constructor() {
|
|
100
|
+
this.counters = fromNames(COUNTER_NAMES, () => 0);
|
|
101
|
+
this.samples = fromNames(HISTOGRAM_NAMES, () => [] as number[]);
|
|
102
|
+
this.ratios = fromNames(RATIO_NAMES, () => null);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Increments a counter by `delta` (default 1). `delta` must be finite and >= 0. */
|
|
106
|
+
public incrementCounter(name: CounterName, delta = 1): void {
|
|
107
|
+
if (!Number.isFinite(delta) || delta < 0) {
|
|
108
|
+
throw new RangeError(`counter delta must be a finite, non-negative number, got ${delta}`);
|
|
109
|
+
}
|
|
110
|
+
this.counters[name] += delta;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Records one observed latency (milliseconds) for a histogram. */
|
|
114
|
+
public observeHistogram(name: HistogramName, valueMs: number): void {
|
|
115
|
+
if (!Number.isFinite(valueMs) || valueMs < 0) {
|
|
116
|
+
throw new RangeError(`histogram value must be a finite, non-negative number, got ${valueMs}`);
|
|
117
|
+
}
|
|
118
|
+
this.samples[name].push(valueMs);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Sets the latest value of a ratio gauge. `null` clears it back to "unknown". */
|
|
122
|
+
public setRatio(name: RatioName, value: number | null): void {
|
|
123
|
+
if (value !== null && (!Number.isFinite(value) || value < 0 || value > 1)) {
|
|
124
|
+
throw new RangeError(`ratio must be null or within [0, 1], got ${value}`);
|
|
125
|
+
}
|
|
126
|
+
this.ratios[name] = value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Point-in-time snapshot as a plain object — no external exporter. */
|
|
130
|
+
public snapshot(): MetricsSnapshotV1 {
|
|
131
|
+
return {
|
|
132
|
+
counters: { ...this.counters },
|
|
133
|
+
histograms: fromNames(HISTOGRAM_NAMES, (name) => summarizeHistogram(this.samples[name])),
|
|
134
|
+
ratios: { ...this.ratios },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Resets every counter, histogram and ratio back to its initial state. */
|
|
139
|
+
public reset(): void {
|
|
140
|
+
for (const name of COUNTER_NAMES) {
|
|
141
|
+
this.counters[name] = 0;
|
|
142
|
+
}
|
|
143
|
+
for (const name of HISTOGRAM_NAMES) {
|
|
144
|
+
this.samples[name] = [];
|
|
145
|
+
}
|
|
146
|
+
for (const name of RATIO_NAMES) {
|
|
147
|
+
this.ratios[name] = null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function fromNames<Name extends string, Value>(
|
|
153
|
+
names: readonly Name[],
|
|
154
|
+
init: (name: Name) => Value,
|
|
155
|
+
): Record<Name, Value> {
|
|
156
|
+
return Object.fromEntries(names.map((name) => [name, init(name)])) as Record<Name, Value>;
|
|
157
|
+
}
|