@elisra-devops/docgen-data-provider 1.140.0 → 1.142.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.
Files changed (74) hide show
  1. package/.github/workflows/ci.yml +2 -0
  2. package/bin/helpers/requestContext.d.ts +51 -0
  3. package/bin/helpers/requestContext.js +218 -0
  4. package/bin/helpers/requestContext.js.map +1 -0
  5. package/bin/helpers/tfs.d.ts +5 -0
  6. package/bin/helpers/tfs.js +24 -24
  7. package/bin/helpers/tfs.js.map +1 -1
  8. package/bin/modules/GitDataProvider.js +3 -2
  9. package/bin/modules/GitDataProvider.js.map +1 -1
  10. package/bin/modules/JfrogDataProvider.js +3 -2
  11. package/bin/modules/JfrogDataProvider.js.map +1 -1
  12. package/bin/modules/PipelinesDataProvider.js +5 -4
  13. package/bin/modules/PipelinesDataProvider.js.map +1 -1
  14. package/bin/modules/ResultDataProvider.js +32 -25
  15. package/bin/modules/ResultDataProvider.js.map +1 -1
  16. package/bin/modules/TestDataProvider.js +4 -3
  17. package/bin/modules/TestDataProvider.js.map +1 -1
  18. package/bin/modules/TicketsDataProvider.js +26 -22
  19. package/bin/modules/TicketsDataProvider.js.map +1 -1
  20. package/bin/tests/helpers/requestContext.test.d.ts +1 -0
  21. package/bin/tests/helpers/requestContext.test.js +165 -0
  22. package/bin/tests/helpers/requestContext.test.js.map +1 -0
  23. package/bin/tests/helpers/tfs.test.js +54 -0
  24. package/bin/tests/helpers/tfs.test.js.map +1 -1
  25. package/bin/tests/modules/JfrogDataProvider.test.js +4 -2
  26. package/bin/tests/modules/JfrogDataProvider.test.js.map +1 -1
  27. package/bin/tests/modules/ResultDataProvider.test.js +9 -3
  28. package/bin/tests/modules/ResultDataProvider.test.js.map +1 -1
  29. package/bin/tests/modules/gitDataProvider.test.js +1 -1
  30. package/bin/tests/modules/gitDataProvider.test.js.map +1 -1
  31. package/bin/tests/modules/pipelineDataProvider.test.js +1 -1
  32. package/bin/tests/modules/pipelineDataProvider.test.js.map +1 -1
  33. package/bin/tests/modules/testDataProvider.test.js +1 -1
  34. package/bin/tests/modules/testDataProvider.test.js.map +1 -1
  35. package/bin/tests/modules/ticketsDataProvider.test.js +2 -2
  36. package/bin/tests/modules/ticketsDataProvider.test.js.map +1 -1
  37. package/bin/tests/utils/logger.test.d.ts +1 -0
  38. package/bin/tests/utils/logger.test.js +332 -0
  39. package/bin/tests/utils/logger.test.js.map +1 -0
  40. package/bin/tests/utils/runContext.test.d.ts +1 -0
  41. package/bin/tests/utils/runContext.test.js +51 -0
  42. package/bin/tests/utils/runContext.test.js.map +1 -0
  43. package/bin/utils/logSink.d.ts +39 -0
  44. package/bin/utils/logSink.js +28 -0
  45. package/bin/utils/logSink.js.map +1 -0
  46. package/bin/utils/logger.d.ts +7 -0
  47. package/bin/utils/logger.js +266 -27
  48. package/bin/utils/logger.js.map +1 -1
  49. package/bin/utils/runContext.d.ts +8 -0
  50. package/bin/utils/runContext.js +12 -0
  51. package/bin/utils/runContext.js.map +1 -0
  52. package/eslint.config.mjs +40 -0
  53. package/package.json +8 -3
  54. package/src/helpers/requestContext.ts +222 -0
  55. package/src/helpers/tfs.ts +30 -24
  56. package/src/modules/GitDataProvider.ts +3 -2
  57. package/src/modules/JfrogDataProvider.ts +3 -2
  58. package/src/modules/PipelinesDataProvider.ts +5 -4
  59. package/src/modules/ResultDataProvider.ts +31 -25
  60. package/src/modules/TestDataProvider.ts +4 -3
  61. package/src/modules/TicketsDataProvider.ts +28 -26
  62. package/src/tests/helpers/requestContext.test.ts +202 -0
  63. package/src/tests/helpers/tfs.test.ts +86 -0
  64. package/src/tests/modules/JfrogDataProvider.test.ts +5 -2
  65. package/src/tests/modules/ResultDataProvider.test.ts +10 -3
  66. package/src/tests/modules/gitDataProvider.test.ts +2 -1
  67. package/src/tests/modules/pipelineDataProvider.test.ts +2 -1
  68. package/src/tests/modules/testDataProvider.test.ts +4 -1
  69. package/src/tests/modules/ticketsDataProvider.test.ts +2 -2
  70. package/src/tests/utils/logger.test.ts +381 -0
  71. package/src/tests/utils/runContext.test.ts +48 -0
  72. package/src/utils/logSink.ts +59 -0
  73. package/src/utils/logger.ts +269 -29
  74. package/src/utils/runContext.ts +25 -0
@@ -1,37 +1,277 @@
1
1
  'use strict';
2
2
  import * as winston from 'winston';
3
- import BrowserConsole from 'winston-transport-browserconsole';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import * as Transport from 'winston-transport';
6
+ import { runContextStore } from './runContext';
7
+ import { getLogSink, DiagnosticEvent, CONTEXT_LIMITS } from './logSink';
4
8
  let logger: winston.Logger;
5
9
 
6
- const logFormat = winston.format.printf((info) => `${info.timestamp} - ${info.level}: ${info.message}`);
7
- // if (typeof window === "undefined") {
8
- // let logsPath = process.env.logs_path || "./logs/";
9
-
10
- // logger = winston.createLogger({
11
- // format: winston.format.timestamp(),
12
- // level: "silly",
13
- // transports: [
14
- // new winston.transports.File({
15
- // filename: `${logsPath}azure-rest-api-errors.log`,
16
- // level: "error",
17
- // format: logFormat,
18
- // }),
19
- // new winston.transports.File({
20
- // filename: `${logsPath}azure-rest-api-all.log`,
21
- // format: logFormat,
22
- // }),
23
- // new winston.transports.Console({ format: logFormat, level: "debug" }),
24
- // ],
25
- // });
26
- // } else {
10
+ // Merges the ambient runId (set by content-control's request middleware, once Phase 3 lands
11
+ // there) into every log record. A never-populated store is a harmless no-op — this package
12
+ // only reads it, never sets it.
13
+ export const withRunContext = winston.format((info) => {
14
+ const store = runContextStore.getStore();
15
+ if (store?.runId) (info as Record<string, unknown>).runId = store.runId;
16
+ // Phase 7b — same ambient, per-run treatment as runId.
17
+ if (store?.docType) (info as Record<string, unknown>).docType = store.docType;
18
+ // Phase 7c — same read-only treatment; populated by content-control's attachRunContext.
19
+ if (store?.project) (info as Record<string, unknown>).project = store.project;
20
+ return info;
21
+ });
22
+
23
+ // Defense-in-depth: scrubs known-sensitive keys out of any object attached to a log call
24
+ // (meta, splat, an Error's own enumerable props — e.g. AxiosError.toJSON()'s
25
+ // config.auth.password / config.headers.Authorization, which is exactly how a PAT was
26
+ // leaking via `logger.error(JSON.stringify(error))` on ADO REST failures). Does not
27
+ // redact secrets interpolated into a message string — that's the corresponding call-site
28
+ // fixes' job; this is a backstop for structured fields.
29
+ // "accesskey" (not just "minioaccesskey") so this also catches an *AccessKeyId-style field —
30
+ // a real gap found in api-gate's copy during the Phase 5 manifest work: only the *SecretKey
31
+ // sibling was covered before, via "secret". Applied here too to keep the four copies in sync.
32
+ const SENSITIVE_KEY = /token|pat|password|secret|authorization|accesskey|minioaccesskey|miniosecretkey/i;
33
+
34
+ // A per-key try/catch on the *read*, not just around the whole loop: a getter that throws
35
+ // (e.g. `{ get boom() { throw ... } }`) would otherwise abort redaction for every remaining
36
+ // key in the same object, or — worse — survive redaction untouched and re-throw later, inside
37
+ // winston's json() formatter, which is not wrapped in anything. redactValue always returns a
38
+ // freshly built plain object/array, never the original reference, so once a value has passed
39
+ // through here nothing downstream can re-trigger a throwing accessor on it.
40
+ function safeRead(obj: Record<string, unknown>, key: string): { ok: true; value: unknown } | { ok: false } {
41
+ try {
42
+ return { ok: true, value: obj[key] };
43
+ } catch {
44
+ return { ok: false };
45
+ }
46
+ }
47
+ export function redactValue(value: unknown, depth = 0, seen = new WeakSet<object>()): unknown {
48
+ if (depth > 6 || value === null || typeof value !== 'object') return value;
49
+ if (seen.has(value as object)) return '[Circular]';
50
+ seen.add(value as object);
51
+ if (Array.isArray(value)) return value.map((v) => redactValue(v, depth + 1, seen));
52
+ const out: Record<string, unknown> = {};
53
+ for (const key of Object.keys(value as Record<string, unknown>)) {
54
+ const read = safeRead(value as Record<string, unknown>, key);
55
+ if (!read.ok) {
56
+ out[key] = '[Unreadable property]';
57
+ continue;
58
+ }
59
+ out[key] = SENSITIVE_KEY.test(key) ? '[REDACTED]' : redactValue(read.value, depth + 1, seen);
60
+ }
61
+ return out;
62
+ }
63
+ export const redact = winston.format((info) => {
64
+ const target = info as Record<string, unknown>;
65
+ for (const key of Object.keys(target)) {
66
+ // level/timestamp are always plain strings winston sets itself — never touched. message is
67
+ // NOT exempt: when logger.error(x) is called with a single non-string/non-Error argument,
68
+ // winston nests the whole thing under info.message (this is exactly the shape hit by a raw
69
+ // object argument), so message can carry a throwing getter or a sensitive key just as much
70
+ // as any other field — redactValue already no-ops on a plain string, so including it here
71
+ // costs nothing for the common case and closes that gap for the uncommon one.
72
+ if (key === 'level' || key === 'timestamp') continue;
73
+ const read = safeRead(target, key);
74
+ const value = !read.ok
75
+ ? '[Unreadable property]'
76
+ : // A top-level primitive (e.g. token: "abc") has no children for redactValue to walk into —
77
+ // the key itself has to be checked here too, not just inside the recursive object walk.
78
+ SENSITIVE_KEY.test(key)
79
+ ? '[REDACTED]'
80
+ : redactValue(read.value);
81
+ try {
82
+ target[key] = value;
83
+ } catch {
84
+ // The original property could be a getter-only accessor with no setter, which throws on
85
+ // plain assignment in strict mode — redefine it outright rather than skip it.
86
+ Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true });
87
+ }
88
+ }
89
+ return info;
90
+ });
91
+
92
+ // Resolves the package's own version for defaultMeta. Node-only (this package is currently
93
+ // only ever consumed by docgen-content-control at runtime — the browser transport below is
94
+ // unused/commented out), so fs/path are safe here.
95
+ function readOwnVersion(): string {
96
+ const candidates = [
97
+ path.resolve(__dirname, '../package.json'),
98
+ path.resolve(__dirname, '../../package.json'),
99
+ path.resolve(process.cwd(), 'package.json'),
100
+ ];
101
+ for (const candidate of candidates) {
102
+ try {
103
+ if (!fs.existsSync(candidate)) continue;
104
+ return JSON.parse(fs.readFileSync(candidate, 'utf8')).version || 'unknown';
105
+ } catch {
106
+ // try next candidate
107
+ }
108
+ }
109
+ return 'unknown';
110
+ }
111
+
112
+ // A template literal's implicit ToString throws on a Symbol value (unlike String(), which
113
+ // calls Symbol.prototype.toString() explicitly) — logger.error(Symbol('x')) would otherwise
114
+ // crash inside this formatter itself, the one place in the pipeline with no try/catch around
115
+ // it. Symbol.toString() itself never throws, so this needs no further guarding.
116
+ const safeMessageString = (value: unknown): string =>
117
+ typeof value === 'symbol' ? value.toString() : String(value);
118
+
119
+ // A failed ADO request's description (helpers/requestContext.ts), attached to the thrown error
120
+ // and merged onto the record by winston. In text mode — the default, which prints only the
121
+ // message — this is the only way the URL reaches stdout for a record whose own message doesn't
122
+ // carry it. Never throws: this runs inside the formatter, which has no try/catch around it.
123
+ const requestSuffix = (ctx: unknown): string => {
124
+ try {
125
+ if (!ctx || typeof ctx !== 'object') return '';
126
+ const c = ctx as Record<string, unknown>;
127
+ if (typeof c.url !== 'string') return '';
128
+ const method = typeof c.method === 'string' ? `${c.method} ` : '';
129
+ const status = typeof c.status === 'number' ? ` -> ${c.status}` : '';
130
+ return ` [${method}${c.url}${status}]`;
131
+ } catch {
132
+ return '';
133
+ }
134
+ };
135
+
136
+ const textFormat = winston.format.printf(
137
+ (info) => `${info.timestamp} - ${info.level}: ${safeMessageString(info.message)}${requestSuffix(info.adoRequest)}`
138
+ );
139
+
140
+ // Bounded so one oversized message/stack can't produce an unbounded LogEvent document.
141
+ const MAX_MESSAGE_LEN = 2000;
142
+ const MAX_STACK_LEN = 4000;
143
+ function clamp(value: unknown, max: number): string | undefined {
144
+ if (typeof value !== 'string') return undefined;
145
+ return value.length > max ? value.slice(0, max) : value;
146
+ }
147
+
148
+ // Re-validates the request description at the transport instead of trusting its shape — it
149
+ // arrives via an Error's own properties, which anything upstream could have set. Allowlisted
150
+ // keys only, each type-checked and bounded.
151
+ function pickContext(raw: unknown): DiagnosticEvent['context'] {
152
+ if (!raw || typeof raw !== 'object') return undefined;
153
+ const c = raw as Record<string, unknown>;
154
+ const context: NonNullable<DiagnosticEvent['context']> = {};
155
+ const method = clamp(c.method, CONTEXT_LIMITS.method);
156
+ const url = clamp(c.url, CONTEXT_LIMITS.url);
157
+ const requestBody = clamp(c.requestBody, CONTEXT_LIMITS.requestBody);
158
+ const responseExcerpt = clamp(c.responseExcerpt, CONTEXT_LIMITS.responseExcerpt);
159
+ if (method) context.method = method;
160
+ if (url) context.url = url;
161
+ if (requestBody) context.requestBody = requestBody;
162
+ if (responseExcerpt) context.responseExcerpt = responseExcerpt;
163
+ if (typeof c.status === 'number' && Number.isFinite(c.status)) context.status = c.status;
164
+ if (typeof c.attempt === 'number' && Number.isFinite(c.attempt)) context.attempt = c.attempt;
165
+ return Object.keys(context).length ? context : undefined;
166
+ }
167
+
168
+ // Ships warn/error records to whatever LogSink the host process (docgen-content-control's
169
+ // index.ts) has installed — this package never installs one itself, only reads it. This is
170
+ // the dominant source of real ADO error records (tfs.ts's executeWithRetry is the single
171
+ // static axios instance handling all Azure DevOps traffic), so without this transport most
172
+ // real generation failures either never reach the store or get misattributed to
173
+ // dg-content-control. Reads info *after* the rest of the format chain has already run —
174
+ // redact() has already scrubbed it by the time this transport sees it. Never throws, never
175
+ // blocks the call path, and never logs through `logger` itself (that would recurse back
176
+ // into this same transport) — failures go to console.error only.
177
+ const DIAGNOSTICS_CAPTURE_ENABLED = (process.env.DIAGNOSTICS_CAPTURE_ENABLED || 'true').toLowerCase() !== 'false';
178
+ // Exported for tests, the same treatment as redact()/withRunContext() — so a test pipeline
179
+ // can exercise this transport without going through the real singleton logger's env-gated
180
+ // json()/textFormat branch.
181
+ export class DiagnosticsTransport extends Transport {
182
+ log(info: Record<string, unknown>, callback: () => void): void {
183
+ setImmediate(() => this.emit('logged', info));
184
+ try {
185
+ const level = info.level;
186
+ const isWarnOrError = level === 'warn' || level === 'error';
187
+ // Phase 6b — the logger's own level gate is now 'debug' (see createLogger below), so a
188
+ // debug/info call reaches this transport regardless of mode; this is the one place that
189
+ // decides whether it's actually persisted, keyed off the per-run capture mode rather
190
+ // than a process-wide setting — concurrent runs in 'normal' mode are unaffected by a
191
+ // sibling run's 'verbose'/'retain-on-failure' opt-in.
192
+ const captureMode = runContextStore.getStore()?.captureMode;
193
+ const isCapturedDebugOrInfo =
194
+ (level === 'debug' || level === 'info') && (captureMode === 'verbose' || captureMode === 'retain-on-failure');
195
+ if (DIAGNOSTICS_CAPTURE_ENABLED && (isWarnOrError || isCapturedDebugOrInfo)) {
196
+ // winston.errors({stack:true}) merges an Error's own enumerable properties (stack,
197
+ // and anything else the call site set, e.g. `err.code`) directly onto `info` — there
198
+ // is no separate nested info.err. `info.stack`'s presence is the only reliable signal
199
+ // that this record came from `logger.error('...', err)`/`logger.error(err)` rather
200
+ // than a plain string message. When present, info.message is already the
201
+ // stable-message-plus-error-message string winston produced, so err.message reuses it.
202
+ const hasErr = typeof info.stack === 'string';
203
+ const message = clamp(info.message, MAX_MESSAGE_LEN) ?? '';
204
+ const event: DiagnosticEvent = {
205
+ ts: typeof info.timestamp === 'string' ? info.timestamp : new Date().toISOString(),
206
+ level: String(info.level),
207
+ service: String(info.service ?? '@elisra-devops/docgen-data-provider'),
208
+ version: String(info.version ?? 'unknown'),
209
+ runId: typeof info.runId === 'string' ? info.runId : undefined,
210
+ docType: typeof info.docType === 'string' ? info.docType : undefined,
211
+ step: typeof info.step === 'string' ? info.step : undefined,
212
+ contentControlType: typeof info.contentControlType === 'string' ? info.contentControlType : undefined,
213
+ contentControlTitle: typeof info.contentControlTitle === 'string' ? info.contentControlTitle : undefined,
214
+ project: typeof info.project === 'string' ? info.project : undefined,
215
+ userId: typeof info.userId === 'string' ? info.userId : undefined,
216
+ message,
217
+ err: hasErr
218
+ ? {
219
+ message,
220
+ code: typeof info.code === 'string' ? info.code : undefined,
221
+ stack: clamp(info.stack, MAX_STACK_LEN),
222
+ }
223
+ : undefined,
224
+ context: pickContext(info.adoRequest),
225
+ retainPending: !isWarnOrError && captureMode === 'retain-on-failure' ? true : undefined,
226
+ };
227
+ getLogSink()?.push(event);
228
+ }
229
+ } catch (e) {
230
+ // eslint-disable-next-line no-console
231
+ console.error('DiagnosticsTransport failed to push event', e);
232
+ }
233
+ callback();
234
+ }
235
+ }
236
+
237
+ // LOG_FORMAT=json is the eventual default (structured stdout a collector can parse), but
238
+ // stays opt-in for one release so switching is a config change, not an image rebuild, if
239
+ // anyone reading raw console output needs to roll back.
240
+ const useJson = (process.env.LOG_FORMAT || 'text').toLowerCase() === 'json';
241
+
242
+ // Phase 6b — the Logger's own level gate must be 'debug' (winston's most permissive) so a
243
+ // debug/info call always reaches every transport's log(); a static per-logger level can't
244
+ // depend on the ALS store's per-run capture mode, so DiagnosticsTransport has to be the one
245
+ // deciding what's actually persisted. The Console transport below gets its own explicit
246
+ // level so stdout's default behavior (info/warn/error unless LOG_LEVEL=debug) is unchanged —
247
+ // this only spends the format-chain cost on debug() calls that previously short-circuited
248
+ // for free; info/warn/error volume (Phase 4's focus) is unaffected either way.
249
+ const CONSOLE_LOG_LEVEL = process.env.LOG_LEVEL || 'info';
250
+
27
251
  logger = winston.createLogger({
28
- format: winston.format.timestamp(),
29
- level: 'silly',
30
- transports: [
31
- new winston.transports.Console({ format: logFormat, level: 'debug' }),
32
- // new BrowserConsole({ format: logFormat, level: 'debug' }),
33
- ],
252
+ level: 'debug',
253
+ defaultMeta: { service: '@elisra-devops/docgen-data-provider', version: readOwnVersion() },
254
+ format: useJson
255
+ ? winston.format.combine(
256
+ winston.format.errors({ stack: true }),
257
+ winston.format.timestamp(),
258
+ withRunContext(),
259
+ redact(),
260
+ winston.format.splat(),
261
+ winston.format.json()
262
+ )
263
+ : winston.format.combine(
264
+ winston.format.errors({ stack: true }),
265
+ winston.format.timestamp(),
266
+ withRunContext(),
267
+ redact(),
268
+ winston.format.splat(),
269
+ textFormat
270
+ ),
271
+ // Stdout only — no File transports, consistent with the other DocGen services (no mounted
272
+ // log volume anywhere in this deployment, so a File transport is invisible to `kubectl logs`
273
+ // and can throw at import time under a read-only root filesystem).
274
+ transports: [new winston.transports.Console({ level: CONSOLE_LOG_LEVEL }), new DiagnosticsTransport()],
34
275
  });
35
- // }
36
276
 
37
277
  export default logger;
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+ import { AsyncLocalStorage } from 'async_hooks';
3
+
4
+ export interface RunContext {
5
+ runId: string;
6
+ // Phase 6b — this package never sets it, only reads it: content-control (the host process
7
+ // this package runs in-process inside) populates it via its own attachRunContext, and this
8
+ // package's DiagnosticsTransport reads the same in-process ALS store.
9
+ captureMode?: 'verbose' | 'retain-on-failure';
10
+ // Phase 7b — same read-only treatment as captureMode above.
11
+ docType?: string;
12
+ // Phase 7c — same read-only treatment; set by content-control's attachRunContext via
13
+ // x-docgen-project, visible here through the shared in-process ALS store.
14
+ project?: string;
15
+ }
16
+
17
+ // Symbol.for uses the global symbol registry, so every duplicated copy of this file across
18
+ // the DocGen packages — hoisted or nested at any depth by npm — converges on the same
19
+ // AsyncLocalStorage instance. Keying by module identity instead would silently split into
20
+ // two stores and runId would go missing with no visible error.
21
+ const KEY = Symbol.for('elisradevops.docgen.runContext');
22
+
23
+ export const runContextStore: AsyncLocalStorage<RunContext> =
24
+ ((globalThis as Record<symbol, unknown>)[KEY] as AsyncLocalStorage<RunContext> | undefined) ??
25
+ ((globalThis as Record<symbol, unknown>)[KEY] = new AsyncLocalStorage<RunContext>());