apitally 1.0.0-beta.3 → 1.0.0-beta.4

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/dist/spool.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"spool.js","names":[],"sources":["../src/spool.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport {\n createWriteStream,\n openSync,\n readdirSync,\n statSync,\n unlinkSync,\n utimesSync,\n type WriteStream,\n writeFileSync,\n} from \"node:fs\";\nimport { readFile, unlink } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { createGzip, type Gzip } from \"node:zlib\";\nimport { logWarning, resetWarning } from \"./logger.js\";\n\nconst WRITE_FAILURE_WARNING = \"Error writing to disk, dropping buffered telemetry\";\n\nconst SIGNALS = [\"traces\", \"logs\", \"metrics\"] as const;\nexport type Signal = (typeof SIGNALS)[number];\n\nconst MAX_UNCOMPRESSED_FILE_SIZE = 4_000_000;\nconst MAX_RETRY_TIME_AFTER_FIRST_ATTEMPT_MILLIS = 59 * 60 * 1000;\nconst MAX_SPOOL_SIZE_DISK = 50_000_000;\nconst MAX_SPOOL_SIZE_MEMORY = 10_000_000;\nconst MAX_UNTOUCHED_FILE_AGE_MILLIS = 2 * 60 * 60 * 1000;\nconst SPOOL_FILE_NAME_PATTERN = /^apitally-.*\\.gz$/;\n\n// Operations for each signal are serialized because gzip and file streams\n// complete asynchronously.\nexport class Spool {\n readonly inMemory: boolean;\n maxSize: number;\n readonly current = new Map<Signal, SpoolFile>();\n private readonly closed: SpoolFile[] = [];\n private readonly tempDir: string;\n private readonly queues: Record<Signal, Promise<void>> = {\n traces: Promise.resolve(),\n logs: Promise.resolve(),\n metrics: Promise.resolve(),\n };\n\n constructor(tempDir: string = tmpdir()) {\n this.tempDir = tempDir;\n this.inMemory = !isTempDirWritable(tempDir);\n if (this.inMemory) {\n logWarning(\n `Unable to create temporary files, buffering telemetry in memory (max ${MAX_SPOOL_SIZE_MEMORY / 1_000_000} MB)`,\n );\n } else {\n cleanupOrphanedFiles(tempDir);\n }\n this.maxSize = this.inMemory ? MAX_SPOOL_SIZE_MEMORY : MAX_SPOOL_SIZE_DISK;\n }\n\n append(signal: Signal, payload: Uint8Array): Promise<void> {\n return this.enqueue(signal, async () => {\n let file = this.current.get(signal);\n if (file && file.uncompressedSize + payload.length > MAX_UNCOMPRESSED_FILE_SIZE) {\n await this.closeCurrentFile(signal);\n file = undefined;\n }\n try {\n if (!file) {\n file = new SpoolFile(signal, this.inMemory ? undefined : this.tempDir);\n this.current.set(signal, file);\n }\n await file.write(payload);\n // Writes recovered; the write-failure warning may fire again later.\n resetWarning(WRITE_FAILURE_WARNING);\n } catch {\n logWarning(WRITE_FAILURE_WARNING);\n await this.discardCurrentFile(signal);\n }\n await this.evict();\n });\n }\n\n // Current files close only when no backlog exists, avoiding one file per cycle.\n // Sequential rotation preserves send order.\n async rotateForExport(): Promise<void> {\n for (const signal of SIGNALS) {\n await this.enqueue(signal, async () => {\n if (this.current.has(signal) && !this.closed.some((file) => file.signal === signal)) {\n await this.closeCurrentFile(signal);\n }\n });\n }\n await this.evict();\n }\n\n async closeCurrentFiles(): Promise<void> {\n for (const signal of SIGNALS) {\n await this.enqueue(signal, () => this.closeCurrentFile(signal));\n }\n }\n\n pendingFiles(): SpoolFile[] {\n return [...this.closed];\n }\n\n async deleteFile(file: SpoolFile): Promise<void> {\n const index = this.closed.indexOf(file);\n if (index >= 0) {\n this.closed.splice(index, 1);\n }\n await file.delete();\n }\n\n // Apitally ingest deduplicates for one hour; a later retry could ingest the file twice.\n deleteIfExpired(file: SpoolFile): Promise<void> | undefined {\n if (!file.isExpired()) {\n return undefined;\n }\n logWarning(`Buffered ${file.signal} could not be delivered within an hour and were dropped`);\n return this.deleteFile(file);\n }\n\n // Refresh mtimes so orphan cleanup in another process preserves live files.\n touchFiles(): void {\n for (const file of [...this.current.values(), ...this.closed]) {\n file.touch();\n }\n }\n\n async clear(): Promise<void> {\n await Promise.all(\n SIGNALS.map((signal) => this.enqueue(signal, () => this.discardCurrentFile(signal))),\n );\n const files = this.closed.splice(0);\n await Promise.all(files.map((file) => file.delete()));\n }\n\n private enqueue(signal: Signal, operation: () => Promise<void>): Promise<void> {\n const result = this.queues[signal].then(operation);\n this.queues[signal] = result.catch(() => undefined);\n return result;\n }\n\n private async closeCurrentFile(signal: Signal): Promise<void> {\n const file = this.current.get(signal);\n if (!file) {\n return;\n }\n this.current.delete(signal);\n try {\n await file.close();\n this.closed.push(file);\n } catch {\n logWarning(WRITE_FAILURE_WARNING);\n await file.delete();\n }\n }\n\n private async discardCurrentFile(signal: Signal): Promise<void> {\n const file = this.current.get(signal);\n if (file) {\n this.current.delete(signal);\n await file.delete();\n }\n }\n\n private async evict(): Promise<void> {\n const deletions = [...this.closed].map((file) => this.deleteIfExpired(file));\n while (this.totalSize() > this.maxSize) {\n // Prefer retaining metrics, but enforce the size limit when only metrics remain.\n const oldest = this.closed.find((file) => file.signal !== \"metrics\") ?? this.closed[0];\n if (!oldest) {\n break;\n }\n logWarning(`Buffer size limit reached, dropping oldest buffered ${oldest.signal}`);\n deletions.push(this.deleteFile(oldest));\n }\n await Promise.all(deletions);\n }\n\n private totalSize(): number {\n let total = 0;\n for (const file of [...this.closed, ...this.current.values()]) {\n total += file.storedSize;\n }\n return total;\n }\n}\n\n// One gzip stream of concatenated OTLP request payloads for a single signal,\n// written through to a temp file or, as fallback, an in-memory buffer.\nexport class SpoolFile {\n readonly signal: Signal;\n readonly path?: string;\n firstAttemptAtMillis?: number;\n uncompressedSize = 0;\n readonly gzip: Gzip;\n private readonly fileStream?: WriteStream;\n private readonly memoryChunks?: Buffer[];\n private memorySize = 0;\n private streamError?: Error;\n private readonly closedPromise: Promise<void>;\n\n constructor(signal: Signal, tempDir?: string) {\n this.signal = signal;\n this.gzip = createGzip();\n let closeTarget: Gzip | WriteStream;\n let closeEvent: string;\n if (tempDir === undefined) {\n const chunks: Buffer[] = [];\n this.memoryChunks = chunks;\n this.gzip.on(\"data\", (chunk: Buffer) => {\n chunks.push(chunk);\n this.memorySize += chunk.length;\n });\n closeTarget = this.gzip;\n closeEvent = \"end\";\n } else {\n this.path = join(tempDir, `apitally-${randomUUID()}.gz`);\n // A synchronous exclusive open reports creation errors to the triggering append.\n const fd = openSync(this.path, \"wx\", 0o600);\n this.fileStream = createWriteStream(this.path, { fd });\n this.gzip.pipe(this.fileStream);\n closeTarget = this.fileStream;\n closeEvent = \"close\";\n }\n const streams: (Gzip | WriteStream)[] = this.fileStream\n ? [this.gzip, this.fileStream]\n : [this.gzip];\n this.closedPromise = new Promise((resolve, reject) => {\n closeTarget.once(closeEvent, resolve);\n for (const stream of streams) {\n stream.once(\"error\", reject);\n }\n });\n // Stream errors can occur outside an awaited operation; rejection handling\n // prevents an unhandled rejection.\n this.closedPromise.catch(() => undefined);\n for (const stream of streams) {\n stream.on(\"error\", (error: Error) => {\n this.streamError ??= error;\n });\n }\n }\n\n write(payload: Uint8Array): Promise<void> {\n return new Promise((resolve, reject) => {\n if (this.streamError) {\n reject(this.streamError);\n return;\n }\n this.gzip.write(payload, (error) => {\n const failure = error ?? this.streamError;\n if (failure) {\n reject(failure);\n } else {\n this.uncompressedSize += payload.length;\n resolve();\n }\n });\n });\n }\n\n async close(): Promise<void> {\n if (this.streamError) {\n throw this.streamError;\n }\n this.gzip.end();\n await this.closedPromise;\n }\n\n get storedSize(): number {\n return this.fileStream ? this.fileStream.bytesWritten : this.memorySize;\n }\n\n markAttempt(): void {\n this.firstAttemptAtMillis ??= performance.now();\n }\n\n isExpired(): boolean {\n return (\n this.firstAttemptAtMillis !== undefined &&\n performance.now() - this.firstAttemptAtMillis > MAX_RETRY_TIME_AFTER_FIRST_ATTEMPT_MILLIS\n );\n }\n\n touch(): void {\n if (this.path) {\n try {\n const now = new Date();\n utimesSync(this.path, now, now);\n } catch {\n // The file may already be gone; orphan cleanup only targets stale files.\n }\n }\n }\n\n readStoredBytes(): Promise<Buffer> {\n if (this.path) {\n return readFile(this.path);\n }\n return Promise.resolve(Buffer.concat(this.memoryChunks ?? []));\n }\n\n async delete(): Promise<void> {\n this.gzip.destroy();\n this.fileStream?.destroy();\n if (this.path) {\n await unlink(this.path).catch(() => undefined);\n }\n }\n}\n\nfunction isTempDirWritable(tempDir: string): boolean {\n const probePath = join(tempDir, `apitally-${randomUUID()}.gz`);\n try {\n writeFileSync(probePath, \"\", { flag: \"wx\", mode: 0o600 });\n unlinkSync(probePath);\n return true;\n } catch {\n return false;\n }\n}\n\n// Live processes refresh mtimes each cycle, so only stale spool files from dead\n// processes are removed.\nfunction cleanupOrphanedFiles(tempDir: string): void {\n const cutoff = Date.now() - MAX_UNTOUCHED_FILE_AGE_MILLIS;\n try {\n for (const name of readdirSync(tempDir)) {\n if (!SPOOL_FILE_NAME_PATTERN.test(name)) {\n continue;\n }\n try {\n const filePath = join(tempDir, name);\n if (statSync(filePath).mtimeMs < cutoff) {\n unlinkSync(filePath);\n }\n } catch {\n // Another process may have removed the file first.\n }\n }\n } catch {\n // Reading the temporary directory is best-effort.\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"spool.js","names":[],"sources":["../src/spool.ts"],"sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport {\n createWriteStream,\n openSync,\n readdirSync,\n statSync,\n unlinkSync,\n utimesSync,\n type WriteStream,\n writeFileSync,\n} from \"node:fs\";\nimport { readFile, unlink } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { createGzip, type Gzip } from \"node:zlib\";\nimport { logWarning, resetWarning } from \"./logger.js\";\n\nconst WRITE_FAILURE_WARNING = \"Error writing to disk, dropping buffered telemetry\";\n\nconst SIGNALS = [\"traces\", \"logs\", \"metrics\"] as const;\nexport type Signal = (typeof SIGNALS)[number];\n\nconst MAX_UNCOMPRESSED_FILE_SIZE = 4_000_000;\nconst MAX_RETRY_TIME_AFTER_FIRST_ATTEMPT_MILLIS = 59 * 60 * 1000;\nconst MAX_SPOOL_SIZE_DISK = 50_000_000;\nconst MAX_SPOOL_SIZE_MEMORY = 10_000_000;\nconst MAX_UNTOUCHED_FILE_AGE_MILLIS = 2 * 60 * 60 * 1000;\nconst SPOOL_FILE_NAME_PATTERN = /^apitally-.*\\.gz$/;\n\n// Operations for each signal are serialized because gzip and file streams\n// complete asynchronously.\nexport class Spool {\n readonly inMemory: boolean;\n maxSize: number;\n readonly current = new Map<Signal, SpoolFile>();\n private readonly closed: SpoolFile[] = [];\n private filesClosedSinceExportRotation = 0;\n private readonly tempDir: string;\n private readonly queues: Record<Signal, Promise<void>> = {\n traces: Promise.resolve(),\n logs: Promise.resolve(),\n metrics: Promise.resolve(),\n };\n\n constructor(tempDir: string = tmpdir()) {\n this.tempDir = tempDir;\n this.inMemory = !isTempDirWritable(tempDir);\n if (this.inMemory) {\n logWarning(\n `Unable to create temporary files, buffering telemetry in memory (max ${MAX_SPOOL_SIZE_MEMORY / 1_000_000} MB)`,\n );\n } else {\n cleanupOrphanedFiles(tempDir);\n }\n this.maxSize = this.inMemory ? MAX_SPOOL_SIZE_MEMORY : MAX_SPOOL_SIZE_DISK;\n }\n\n append(signal: Signal, payload: Uint8Array): Promise<void> {\n return this.enqueue(signal, async () => {\n let file = this.current.get(signal);\n if (file && file.uncompressedSize + payload.length > MAX_UNCOMPRESSED_FILE_SIZE) {\n await this.closeCurrentFile(signal);\n file = undefined;\n }\n try {\n if (!file) {\n file = new SpoolFile(signal, this.inMemory ? undefined : this.tempDir);\n this.current.set(signal, file);\n }\n await file.write(payload);\n // Writes recovered; the write-failure warning may fire again later.\n resetWarning(WRITE_FAILURE_WARNING);\n } catch {\n logWarning(WRITE_FAILURE_WARNING);\n await this.discardCurrentFile(signal);\n }\n await this.evict();\n });\n }\n\n // Current files close only when no backlog exists, avoiding one file per cycle.\n // Sequential rotation preserves send order. Returns the number of files closed\n // since the previous call, including files closed at the size limit.\n async rotateForExport(): Promise<number> {\n for (const signal of SIGNALS) {\n await this.enqueue(signal, async () => {\n if (this.current.has(signal) && !this.closed.some((file) => file.signal === signal)) {\n await this.closeCurrentFile(signal);\n }\n });\n }\n await this.evict();\n const closedFileCount = this.filesClosedSinceExportRotation;\n this.filesClosedSinceExportRotation = 0;\n return closedFileCount;\n }\n\n async closeCurrentFiles(): Promise<void> {\n for (const signal of SIGNALS) {\n await this.enqueue(signal, () => this.closeCurrentFile(signal));\n }\n }\n\n pendingFiles(): SpoolFile[] {\n return [...this.closed];\n }\n\n async deleteFile(file: SpoolFile): Promise<void> {\n const index = this.closed.indexOf(file);\n if (index >= 0) {\n this.closed.splice(index, 1);\n }\n await file.delete();\n }\n\n // Apitally ingest deduplicates for one hour; a later retry could ingest the file twice.\n deleteIfExpired(file: SpoolFile): Promise<void> | undefined {\n if (!file.isExpired()) {\n return undefined;\n }\n logWarning(`Buffered ${file.signal} could not be delivered within an hour and were dropped`);\n return this.deleteFile(file);\n }\n\n // Refresh mtimes so orphan cleanup in another process preserves live files.\n touchFiles(): void {\n for (const file of [...this.current.values(), ...this.closed]) {\n file.touch();\n }\n }\n\n async clear(): Promise<void> {\n await Promise.all(\n SIGNALS.map((signal) => this.enqueue(signal, () => this.discardCurrentFile(signal))),\n );\n const files = this.closed.splice(0);\n await Promise.all(files.map((file) => file.delete()));\n }\n\n private enqueue(signal: Signal, operation: () => Promise<void>): Promise<void> {\n const result = this.queues[signal].then(operation);\n this.queues[signal] = result.catch(() => undefined);\n return result;\n }\n\n private async closeCurrentFile(signal: Signal): Promise<void> {\n const file = this.current.get(signal);\n if (!file) {\n return;\n }\n this.current.delete(signal);\n try {\n await file.close();\n this.closed.push(file);\n this.filesClosedSinceExportRotation += 1;\n } catch {\n logWarning(WRITE_FAILURE_WARNING);\n await file.delete();\n }\n }\n\n private async discardCurrentFile(signal: Signal): Promise<void> {\n const file = this.current.get(signal);\n if (file) {\n this.current.delete(signal);\n await file.delete();\n }\n }\n\n private async evict(): Promise<void> {\n const deletions = [...this.closed].map((file) => this.deleteIfExpired(file));\n while (this.totalSize() > this.maxSize) {\n // Prefer retaining metrics, but enforce the size limit when only metrics remain.\n const oldest = this.closed.find((file) => file.signal !== \"metrics\") ?? this.closed[0];\n if (!oldest) {\n break;\n }\n logWarning(`Buffer size limit reached, dropping oldest buffered ${oldest.signal}`);\n deletions.push(this.deleteFile(oldest));\n }\n await Promise.all(deletions);\n }\n\n private totalSize(): number {\n let total = 0;\n for (const file of [...this.closed, ...this.current.values()]) {\n total += file.storedSize;\n }\n return total;\n }\n}\n\n// One gzip stream of concatenated OTLP request payloads for a single signal,\n// written through to a temp file or, as fallback, an in-memory buffer.\nexport class SpoolFile {\n readonly signal: Signal;\n readonly path?: string;\n firstAttemptAtMillis?: number;\n uncompressedSize = 0;\n readonly gzip: Gzip;\n private readonly fileStream?: WriteStream;\n private readonly memoryChunks?: Buffer[];\n private memorySize = 0;\n private streamError?: Error;\n private readonly closedPromise: Promise<void>;\n\n constructor(signal: Signal, tempDir?: string) {\n this.signal = signal;\n this.gzip = createGzip();\n let closeTarget: Gzip | WriteStream;\n let closeEvent: string;\n if (tempDir === undefined) {\n const chunks: Buffer[] = [];\n this.memoryChunks = chunks;\n this.gzip.on(\"data\", (chunk: Buffer) => {\n chunks.push(chunk);\n this.memorySize += chunk.length;\n });\n closeTarget = this.gzip;\n closeEvent = \"end\";\n } else {\n this.path = join(tempDir, `apitally-${randomUUID()}.gz`);\n // A synchronous exclusive open reports creation errors to the triggering append.\n const fd = openSync(this.path, \"wx\", 0o600);\n this.fileStream = createWriteStream(this.path, { fd });\n this.gzip.pipe(this.fileStream);\n closeTarget = this.fileStream;\n closeEvent = \"close\";\n }\n const streams: (Gzip | WriteStream)[] = this.fileStream\n ? [this.gzip, this.fileStream]\n : [this.gzip];\n this.closedPromise = new Promise((resolve, reject) => {\n closeTarget.once(closeEvent, resolve);\n for (const stream of streams) {\n stream.once(\"error\", reject);\n }\n });\n // Stream errors can occur outside an awaited operation; rejection handling\n // prevents an unhandled rejection.\n this.closedPromise.catch(() => undefined);\n for (const stream of streams) {\n stream.on(\"error\", (error: Error) => {\n this.streamError ??= error;\n });\n }\n }\n\n write(payload: Uint8Array): Promise<void> {\n return new Promise((resolve, reject) => {\n if (this.streamError) {\n reject(this.streamError);\n return;\n }\n this.gzip.write(payload, (error) => {\n const failure = error ?? this.streamError;\n if (failure) {\n reject(failure);\n } else {\n this.uncompressedSize += payload.length;\n resolve();\n }\n });\n });\n }\n\n async close(): Promise<void> {\n if (this.streamError) {\n throw this.streamError;\n }\n this.gzip.end();\n await this.closedPromise;\n }\n\n get storedSize(): number {\n return this.fileStream ? this.fileStream.bytesWritten : this.memorySize;\n }\n\n markAttempt(): void {\n this.firstAttemptAtMillis ??= performance.now();\n }\n\n isExpired(): boolean {\n return (\n this.firstAttemptAtMillis !== undefined &&\n performance.now() - this.firstAttemptAtMillis > MAX_RETRY_TIME_AFTER_FIRST_ATTEMPT_MILLIS\n );\n }\n\n touch(): void {\n if (this.path) {\n try {\n const now = new Date();\n utimesSync(this.path, now, now);\n } catch {\n // The file may already be gone; orphan cleanup only targets stale files.\n }\n }\n }\n\n readStoredBytes(): Promise<Buffer> {\n if (this.path) {\n return readFile(this.path);\n }\n return Promise.resolve(Buffer.concat(this.memoryChunks ?? []));\n }\n\n async delete(): Promise<void> {\n this.gzip.destroy();\n this.fileStream?.destroy();\n if (this.path) {\n await unlink(this.path).catch(() => undefined);\n }\n }\n}\n\nfunction isTempDirWritable(tempDir: string): boolean {\n const probePath = join(tempDir, `apitally-${randomUUID()}.gz`);\n try {\n writeFileSync(probePath, \"\", { flag: \"wx\", mode: 0o600 });\n unlinkSync(probePath);\n return true;\n } catch {\n return false;\n }\n}\n\n// Live processes refresh mtimes each cycle, so only stale spool files from dead\n// processes are removed.\nfunction cleanupOrphanedFiles(tempDir: string): void {\n const cutoff = Date.now() - MAX_UNTOUCHED_FILE_AGE_MILLIS;\n try {\n for (const name of readdirSync(tempDir)) {\n if (!SPOOL_FILE_NAME_PATTERN.test(name)) {\n continue;\n }\n try {\n const filePath = join(tempDir, name);\n if (statSync(filePath).mtimeMs < cutoff) {\n unlinkSync(filePath);\n }\n } catch {\n // Another process may have removed the file first.\n }\n }\n } catch {\n // Reading the temporary directory is best-effort.\n }\n}\n"],"mappings":"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"}
@@ -3,14 +3,13 @@ const require_bodyCapture = require("./bodyCapture.cjs");
3
3
  let node_zlib = require("node:zlib");
4
4
  //#region src/validationErrors.ts
5
5
  const VALIDATION_ERROR_EVENT_NAME = "apitally.request.validation_error";
6
- const MAX_GROUPS = 100;
6
+ const MAX_ERRORS = 100;
7
7
  const MAX_CONSUMER_LENGTH = 128;
8
8
  const MAX_PATH_LENGTH = 2e3;
9
9
  const MAX_SOURCE_LENGTH = 32;
10
10
  const MAX_FIELD_LENGTH = 2048;
11
11
  const MAX_MESSAGE_LENGTH = 2048;
12
12
  const MAX_TYPE_LENGTH = 128;
13
- const MAX_COUNT = 2 ** 32 - 1;
14
13
  const SOURCE_ALIASES = {
15
14
  body: "body",
16
15
  query: "query",
@@ -23,48 +22,52 @@ const SOURCE_ALIASES = {
23
22
  headers: "header",
24
23
  cookies: "cookie"
25
24
  };
26
- const VALIDATION_ERROR_GROUPS_KEY = Symbol.for("apitally.validationErrorGroups");
27
- const validationErrorGroupsHolder = globalThis;
28
- const validationErrorGroups = validationErrorGroupsHolder[VALIDATION_ERROR_GROUPS_KEY] ?? /* @__PURE__ */ new Map();
29
- validationErrorGroupsHolder[VALIDATION_ERROR_GROUPS_KEY] = validationErrorGroups;
25
+ const VALIDATION_ERROR_AGGREGATES_KEY = Symbol.for("apitally.validationErrorAggregates");
26
+ const validationErrorAggregatesHolder = globalThis;
27
+ const validationErrorAggregates = validationErrorAggregatesHolder[VALIDATION_ERROR_AGGREGATES_KEY] ?? /* @__PURE__ */ new Map();
28
+ validationErrorAggregatesHolder[VALIDATION_ERROR_AGGREGATES_KEY] = validationErrorAggregates;
30
29
  function isValidationResponseStatus(statusCode) {
31
30
  return statusCode === 400 || statusCode === 422;
32
31
  }
33
32
  function addValidationErrors(consumer, method, path, details) {
34
33
  method = method.toUpperCase();
35
34
  if (method === "OPTIONS" || !path) return;
35
+ consumer = consumer?.slice(0, MAX_CONSUMER_LENGTH);
36
36
  for (const detail of details) {
37
- const group = {
37
+ const fields = {
38
38
  method,
39
39
  path: path.slice(0, MAX_PATH_LENGTH),
40
40
  source: detail.source.slice(0, MAX_SOURCE_LENGTH),
41
41
  field: detail.field.slice(0, MAX_FIELD_LENGTH),
42
42
  message: detail.message.slice(0, MAX_MESSAGE_LENGTH),
43
- type: detail.type.slice(0, MAX_TYPE_LENGTH),
44
- count: 1
43
+ type: detail.type.slice(0, MAX_TYPE_LENGTH)
45
44
  };
46
- if (consumer !== void 0) group.consumer = consumer.slice(0, MAX_CONSUMER_LENGTH);
47
- const key = [
48
- group.consumer ?? "",
49
- group.method,
50
- group.path,
51
- group.source,
52
- group.field,
53
- group.message,
54
- group.type
55
- ].join("\0");
56
- const existing = validationErrorGroups.get(key);
57
- if (existing) existing.count = Math.min(existing.count + 1, MAX_COUNT);
58
- else if (validationErrorGroups.size < MAX_GROUPS) validationErrorGroups.set(key, group);
45
+ const key = Object.values(fields).join("\0");
46
+ let aggregate = validationErrorAggregates.get(key);
47
+ if (!aggregate) {
48
+ if (validationErrorAggregates.size >= MAX_ERRORS) continue;
49
+ aggregate = {
50
+ ...fields,
51
+ counts: /* @__PURE__ */ new Map()
52
+ };
53
+ validationErrorAggregates.set(key, aggregate);
54
+ }
55
+ aggregate.counts.set(consumer, (aggregate.counts.get(consumer) ?? 0) + 1);
59
56
  }
60
57
  }
61
58
  function drainValidationErrors() {
62
- const groups = [...validationErrorGroups.values()];
63
- validationErrorGroups.clear();
64
- return groups;
59
+ const aggregates = [...validationErrorAggregates.values()];
60
+ validationErrorAggregates.clear();
61
+ return aggregates.map(({ counts, ...fields }) => ({
62
+ ...fields,
63
+ counts: [...counts].map(([consumer, count]) => consumer === void 0 ? { count } : {
64
+ consumer,
65
+ count
66
+ })
67
+ }));
65
68
  }
66
69
  function resetValidationErrors() {
67
- validationErrorGroups.clear();
70
+ validationErrorAggregates.clear();
68
71
  }
69
72
  function normalizeSource(source) {
70
73
  return typeof source === "string" ? SOURCE_ALIASES[source.toLowerCase()] ?? "" : "";
@@ -1 +1 @@
1
- {"version":3,"file":"validationErrors.cjs","names":["brotliDecompressSync","MAX_BODY_SIZE","unzipSync"],"sources":["../src/validationErrors.ts"],"sourcesContent":["import { brotliDecompressSync, unzipSync } from \"node:zlib\";\nimport type { AnyValueMap } from \"@opentelemetry/api-logs\";\nimport { MAX_BODY_SIZE } from \"./bodyCapture.js\";\n\nexport const VALIDATION_ERROR_EVENT_NAME = \"apitally.request.validation_error\";\n\nconst MAX_GROUPS = 100;\nconst MAX_CONSUMER_LENGTH = 128;\nconst MAX_PATH_LENGTH = 2_000;\nconst MAX_SOURCE_LENGTH = 32;\nconst MAX_FIELD_LENGTH = 2_048;\nconst MAX_MESSAGE_LENGTH = 2_048;\nconst MAX_TYPE_LENGTH = 128;\nconst MAX_COUNT = 2 ** 32 - 1;\n\nconst SOURCE_ALIASES: Record<string, string> = {\n body: \"body\",\n query: \"query\",\n path: \"path\",\n header: \"header\",\n cookie: \"cookie\",\n querystring: \"query\",\n params: \"path\",\n path_params: \"path\",\n headers: \"header\",\n cookies: \"cookie\",\n};\n\nexport type ValidationErrorDetail = {\n source: string;\n field: string;\n message: string;\n type: string;\n};\n\ntype ValidationErrorGroup = ValidationErrorDetail & {\n consumer?: string;\n method: string;\n path: string;\n count: number;\n};\n\nconst VALIDATION_ERROR_GROUPS_KEY = Symbol.for(\"apitally.validationErrorGroups\");\nconst validationErrorGroupsHolder = globalThis as Record<\n symbol,\n Map<string, ValidationErrorGroup> | undefined\n>;\nconst validationErrorGroups =\n validationErrorGroupsHolder[VALIDATION_ERROR_GROUPS_KEY] ??\n new Map<string, ValidationErrorGroup>();\nvalidationErrorGroupsHolder[VALIDATION_ERROR_GROUPS_KEY] = validationErrorGroups;\n\nexport function isValidationResponseStatus(statusCode: number): boolean {\n return statusCode === 400 || statusCode === 422;\n}\n\nexport function addValidationErrors(\n consumer: string | undefined,\n method: string,\n path: string,\n details: ValidationErrorDetail[],\n): void {\n method = method.toUpperCase();\n if (method === \"OPTIONS\" || !path) {\n return;\n }\n for (const detail of details) {\n const group: ValidationErrorGroup = {\n method,\n path: path.slice(0, MAX_PATH_LENGTH),\n source: detail.source.slice(0, MAX_SOURCE_LENGTH),\n field: detail.field.slice(0, MAX_FIELD_LENGTH),\n message: detail.message.slice(0, MAX_MESSAGE_LENGTH),\n type: detail.type.slice(0, MAX_TYPE_LENGTH),\n count: 1,\n };\n if (consumer !== undefined) {\n group.consumer = consumer.slice(0, MAX_CONSUMER_LENGTH);\n }\n const key = [\n group.consumer ?? \"\",\n group.method,\n group.path,\n group.source,\n group.field,\n group.message,\n group.type,\n ].join(\"\\0\");\n const existing = validationErrorGroups.get(key);\n if (existing) {\n existing.count = Math.min(existing.count + 1, MAX_COUNT);\n } else if (validationErrorGroups.size < MAX_GROUPS) {\n validationErrorGroups.set(key, group);\n }\n }\n}\n\nexport function drainValidationErrors(): AnyValueMap[] {\n const groups = [...validationErrorGroups.values()];\n validationErrorGroups.clear();\n return groups;\n}\n\nexport function resetValidationErrors(): void {\n validationErrorGroups.clear();\n}\n\nexport function normalizeSource(source: unknown): string {\n return typeof source === \"string\" ? (SOURCE_ALIASES[source.toLowerCase()] ?? \"\") : \"\";\n}\n\n// Zod, Standard Schema, and TypeBox issues share this shape. A Standard Schema\n// path segment may be an object carrying the key; a TypeBox path is a JSON pointer.\nexport function formatIssues(issues: unknown, source = \"\"): ValidationErrorDetail[] {\n if (!Array.isArray(issues)) {\n return [];\n }\n const details: ValidationErrorDetail[] = [];\n for (const issue of issues) {\n if (!isRecord(issue) || typeof issue.message !== \"string\") {\n continue;\n }\n const path = Array.isArray(issue.path)\n ? issue.path\n : typeof issue.path === \"string\"\n ? issue.path\n .split(\"/\")\n .filter(Boolean)\n .map((segment) => segment.replace(/~1/g, \"/\").replace(/~0/g, \"~\"))\n : [];\n details.push({\n source,\n field: path\n .map((segment: unknown) => (isRecord(segment) ? segment.key : segment))\n .filter((segment) => typeof segment === \"string\" || typeof segment === \"number\")\n .join(\".\"),\n message: issue.message,\n type: typeof issue.code === \"string\" ? issue.code : \"\",\n });\n }\n return details;\n}\n\n// zod 3 serializes issues as `error.issues`; zod 4 hides them as a JSON string\n// in `error.message`.\nexport function extractZodValidationErrors(body: unknown): ValidationErrorDetail[] {\n if (!isRecord(body)) {\n return [];\n }\n const error = isRecord(body.error) ? body.error : undefined;\n let issues = body.issues ?? error?.issues;\n if (issues === undefined && error?.name === \"ZodError\" && typeof error.message === \"string\") {\n try {\n issues = JSON.parse(error.message);\n } catch {\n return [];\n }\n }\n return formatIssues(issues);\n}\n\n// Recognized response shapes: zod issues, express-validator, and celebrate.\nexport function extractExpressValidationErrors(body: unknown): ValidationErrorDetail[] {\n if (!isRecord(body)) {\n return [];\n }\n const zodDetails = extractZodValidationErrors(body);\n if (zodDetails.length > 0) {\n return zodDetails;\n }\n if (Array.isArray(body.errors)) {\n return extractExpressValidatorErrors(body.errors);\n }\n if (isRecord(body.validation)) {\n return extractCelebrateErrors(body.validation);\n }\n return [];\n}\n\nfunction extractExpressValidatorErrors(errors: unknown[]): ValidationErrorDetail[] {\n const details: ValidationErrorDetail[] = [];\n for (const error of errors) {\n if (\n !isRecord(error) ||\n error.type !== \"field\" ||\n typeof error.path !== \"string\" ||\n typeof error.msg !== \"string\"\n ) {\n continue;\n }\n const source = normalizeSource(error.location);\n if (source) {\n details.push({ source, field: error.path, message: error.msg, type: \"\" });\n }\n }\n return details;\n}\n\n// Celebrate joins the messages of all failed keys into one sentence list, so\n// each key takes the sentence naming it.\nfunction extractCelebrateErrors(validation: Record<string, unknown>): ValidationErrorDetail[] {\n const details: ValidationErrorDetail[] = [];\n for (const error of Object.values(validation)) {\n if (!isRecord(error) || !Array.isArray(error.keys) || typeof error.message !== \"string\") {\n continue;\n }\n const sentences = error.message.split(\". \");\n for (const key of error.keys) {\n if (typeof key === \"string\") {\n details.push({\n source: normalizeSource(error.source),\n field: key,\n message: sentences.find((sentence) => sentence.includes(`\"${key}\"`)) ?? error.message,\n type: \"\",\n });\n }\n }\n }\n return details;\n}\n\nexport function parseJsonResponseBody(body: Buffer | undefined, contentEncoding: unknown): unknown {\n if (!body) {\n return undefined;\n }\n try {\n const encoding =\n typeof contentEncoding === \"string\" ? contentEncoding.trim().toLowerCase() : \"\";\n let decoded = body;\n if (encoding === \"br\") {\n decoded = brotliDecompressSync(body, { maxOutputLength: MAX_BODY_SIZE });\n } else if (encoding === \"gzip\" || encoding === \"deflate\") {\n decoded = unzipSync(body, { maxOutputLength: MAX_BODY_SIZE });\n } else if (encoding !== \"\" && encoding !== \"identity\") {\n return undefined;\n }\n return JSON.parse(decoded.toString());\n } catch {\n // A body that cannot be decoded as JSON carries no recognizable validation details.\n }\n return undefined;\n}\n\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"validationErrors.cjs","names":["brotliDecompressSync","MAX_BODY_SIZE","unzipSync"],"sources":["../src/validationErrors.ts"],"sourcesContent":["import { brotliDecompressSync, unzipSync } from \"node:zlib\";\nimport type { AnyValueMap } from \"@opentelemetry/api-logs\";\nimport { MAX_BODY_SIZE } from \"./bodyCapture.js\";\n\nexport const VALIDATION_ERROR_EVENT_NAME = \"apitally.request.validation_error\";\n\nconst MAX_ERRORS = 100;\nconst MAX_CONSUMER_LENGTH = 128;\nconst MAX_PATH_LENGTH = 2_000;\nconst MAX_SOURCE_LENGTH = 32;\nconst MAX_FIELD_LENGTH = 2_048;\nconst MAX_MESSAGE_LENGTH = 2_048;\nconst MAX_TYPE_LENGTH = 128;\n\nconst SOURCE_ALIASES: Record<string, string> = {\n body: \"body\",\n query: \"query\",\n path: \"path\",\n header: \"header\",\n cookie: \"cookie\",\n querystring: \"query\",\n params: \"path\",\n path_params: \"path\",\n headers: \"header\",\n cookies: \"cookie\",\n};\n\nexport type ValidationErrorDetail = {\n source: string;\n field: string;\n message: string;\n type: string;\n};\n\ntype ValidationErrorAggregate = ValidationErrorDetail & {\n method: string;\n path: string;\n counts: Map<string | undefined, number>;\n};\n\nconst VALIDATION_ERROR_AGGREGATES_KEY = Symbol.for(\"apitally.validationErrorAggregates\");\nconst validationErrorAggregatesHolder = globalThis as Record<\n symbol,\n Map<string, ValidationErrorAggregate> | undefined\n>;\nconst validationErrorAggregates =\n validationErrorAggregatesHolder[VALIDATION_ERROR_AGGREGATES_KEY] ??\n new Map<string, ValidationErrorAggregate>();\nvalidationErrorAggregatesHolder[VALIDATION_ERROR_AGGREGATES_KEY] = validationErrorAggregates;\n\nexport function isValidationResponseStatus(statusCode: number): boolean {\n return statusCode === 400 || statusCode === 422;\n}\n\nexport function addValidationErrors(\n consumer: string | undefined,\n method: string,\n path: string,\n details: ValidationErrorDetail[],\n): void {\n method = method.toUpperCase();\n if (method === \"OPTIONS\" || !path) {\n return;\n }\n consumer = consumer?.slice(0, MAX_CONSUMER_LENGTH);\n for (const detail of details) {\n const fields = {\n method,\n path: path.slice(0, MAX_PATH_LENGTH),\n source: detail.source.slice(0, MAX_SOURCE_LENGTH),\n field: detail.field.slice(0, MAX_FIELD_LENGTH),\n message: detail.message.slice(0, MAX_MESSAGE_LENGTH),\n type: detail.type.slice(0, MAX_TYPE_LENGTH),\n };\n const key = Object.values(fields).join(\"\\0\");\n let aggregate = validationErrorAggregates.get(key);\n if (!aggregate) {\n if (validationErrorAggregates.size >= MAX_ERRORS) {\n continue;\n }\n aggregate = { ...fields, counts: new Map() };\n validationErrorAggregates.set(key, aggregate);\n }\n aggregate.counts.set(consumer, (aggregate.counts.get(consumer) ?? 0) + 1);\n }\n}\n\nexport function drainValidationErrors(): AnyValueMap[] {\n const aggregates = [...validationErrorAggregates.values()];\n validationErrorAggregates.clear();\n return aggregates.map(({ counts, ...fields }) => ({\n ...fields,\n counts: [...counts].map(([consumer, count]) =>\n consumer === undefined ? { count } : { consumer, count },\n ),\n }));\n}\n\nexport function resetValidationErrors(): void {\n validationErrorAggregates.clear();\n}\n\nexport function normalizeSource(source: unknown): string {\n return typeof source === \"string\" ? (SOURCE_ALIASES[source.toLowerCase()] ?? \"\") : \"\";\n}\n\n// Zod, Standard Schema, and TypeBox issues share this shape. A Standard Schema\n// path segment may be an object carrying the key; a TypeBox path is a JSON pointer.\nexport function formatIssues(issues: unknown, source = \"\"): ValidationErrorDetail[] {\n if (!Array.isArray(issues)) {\n return [];\n }\n const details: ValidationErrorDetail[] = [];\n for (const issue of issues) {\n if (!isRecord(issue) || typeof issue.message !== \"string\") {\n continue;\n }\n const path = Array.isArray(issue.path)\n ? issue.path\n : typeof issue.path === \"string\"\n ? issue.path\n .split(\"/\")\n .filter(Boolean)\n .map((segment) => segment.replace(/~1/g, \"/\").replace(/~0/g, \"~\"))\n : [];\n details.push({\n source,\n field: path\n .map((segment: unknown) => (isRecord(segment) ? segment.key : segment))\n .filter((segment) => typeof segment === \"string\" || typeof segment === \"number\")\n .join(\".\"),\n message: issue.message,\n type: typeof issue.code === \"string\" ? issue.code : \"\",\n });\n }\n return details;\n}\n\n// zod 3 serializes issues as `error.issues`; zod 4 hides them as a JSON string\n// in `error.message`.\nexport function extractZodValidationErrors(body: unknown): ValidationErrorDetail[] {\n if (!isRecord(body)) {\n return [];\n }\n const error = isRecord(body.error) ? body.error : undefined;\n let issues = body.issues ?? error?.issues;\n if (issues === undefined && error?.name === \"ZodError\" && typeof error.message === \"string\") {\n try {\n issues = JSON.parse(error.message);\n } catch {\n return [];\n }\n }\n return formatIssues(issues);\n}\n\n// Recognized response shapes: zod issues, express-validator, and celebrate.\nexport function extractExpressValidationErrors(body: unknown): ValidationErrorDetail[] {\n if (!isRecord(body)) {\n return [];\n }\n const zodDetails = extractZodValidationErrors(body);\n if (zodDetails.length > 0) {\n return zodDetails;\n }\n if (Array.isArray(body.errors)) {\n return extractExpressValidatorErrors(body.errors);\n }\n if (isRecord(body.validation)) {\n return extractCelebrateErrors(body.validation);\n }\n return [];\n}\n\nfunction extractExpressValidatorErrors(errors: unknown[]): ValidationErrorDetail[] {\n const details: ValidationErrorDetail[] = [];\n for (const error of errors) {\n if (\n !isRecord(error) ||\n error.type !== \"field\" ||\n typeof error.path !== \"string\" ||\n typeof error.msg !== \"string\"\n ) {\n continue;\n }\n const source = normalizeSource(error.location);\n if (source) {\n details.push({ source, field: error.path, message: error.msg, type: \"\" });\n }\n }\n return details;\n}\n\n// Celebrate joins the messages of all failed keys into one sentence list, so\n// each key takes the sentence naming it.\nfunction extractCelebrateErrors(validation: Record<string, unknown>): ValidationErrorDetail[] {\n const details: ValidationErrorDetail[] = [];\n for (const error of Object.values(validation)) {\n if (!isRecord(error) || !Array.isArray(error.keys) || typeof error.message !== \"string\") {\n continue;\n }\n const sentences = error.message.split(\". \");\n for (const key of error.keys) {\n if (typeof key === \"string\") {\n details.push({\n source: normalizeSource(error.source),\n field: key,\n message: sentences.find((sentence) => sentence.includes(`\"${key}\"`)) ?? error.message,\n type: \"\",\n });\n }\n }\n }\n return details;\n}\n\nexport function parseJsonResponseBody(body: Buffer | undefined, contentEncoding: unknown): unknown {\n if (!body) {\n return undefined;\n }\n try {\n const encoding =\n typeof contentEncoding === \"string\" ? contentEncoding.trim().toLowerCase() : \"\";\n let decoded = body;\n if (encoding === \"br\") {\n decoded = brotliDecompressSync(body, { maxOutputLength: MAX_BODY_SIZE });\n } else if (encoding === \"gzip\" || encoding === \"deflate\") {\n decoded = unzipSync(body, { maxOutputLength: MAX_BODY_SIZE });\n } else if (encoding !== \"\" && encoding !== \"identity\") {\n return undefined;\n }\n return JSON.parse(decoded.toString());\n } catch {\n // A body that cannot be decoded as JSON carries no recognizable validation details.\n }\n return undefined;\n}\n\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null;\n}\n"],"mappings":"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"}
@@ -2,14 +2,13 @@ import { MAX_BODY_SIZE } from "./bodyCapture.js";
2
2
  import { brotliDecompressSync, unzipSync } from "node:zlib";
3
3
  //#region src/validationErrors.ts
4
4
  const VALIDATION_ERROR_EVENT_NAME = "apitally.request.validation_error";
5
- const MAX_GROUPS = 100;
5
+ const MAX_ERRORS = 100;
6
6
  const MAX_CONSUMER_LENGTH = 128;
7
7
  const MAX_PATH_LENGTH = 2e3;
8
8
  const MAX_SOURCE_LENGTH = 32;
9
9
  const MAX_FIELD_LENGTH = 2048;
10
10
  const MAX_MESSAGE_LENGTH = 2048;
11
11
  const MAX_TYPE_LENGTH = 128;
12
- const MAX_COUNT = 2 ** 32 - 1;
13
12
  const SOURCE_ALIASES = {
14
13
  body: "body",
15
14
  query: "query",
@@ -22,48 +21,52 @@ const SOURCE_ALIASES = {
22
21
  headers: "header",
23
22
  cookies: "cookie"
24
23
  };
25
- const VALIDATION_ERROR_GROUPS_KEY = Symbol.for("apitally.validationErrorGroups");
26
- const validationErrorGroupsHolder = globalThis;
27
- const validationErrorGroups = validationErrorGroupsHolder[VALIDATION_ERROR_GROUPS_KEY] ?? /* @__PURE__ */ new Map();
28
- validationErrorGroupsHolder[VALIDATION_ERROR_GROUPS_KEY] = validationErrorGroups;
24
+ const VALIDATION_ERROR_AGGREGATES_KEY = Symbol.for("apitally.validationErrorAggregates");
25
+ const validationErrorAggregatesHolder = globalThis;
26
+ const validationErrorAggregates = validationErrorAggregatesHolder[VALIDATION_ERROR_AGGREGATES_KEY] ?? /* @__PURE__ */ new Map();
27
+ validationErrorAggregatesHolder[VALIDATION_ERROR_AGGREGATES_KEY] = validationErrorAggregates;
29
28
  function isValidationResponseStatus(statusCode) {
30
29
  return statusCode === 400 || statusCode === 422;
31
30
  }
32
31
  function addValidationErrors(consumer, method, path, details) {
33
32
  method = method.toUpperCase();
34
33
  if (method === "OPTIONS" || !path) return;
34
+ consumer = consumer?.slice(0, MAX_CONSUMER_LENGTH);
35
35
  for (const detail of details) {
36
- const group = {
36
+ const fields = {
37
37
  method,
38
38
  path: path.slice(0, MAX_PATH_LENGTH),
39
39
  source: detail.source.slice(0, MAX_SOURCE_LENGTH),
40
40
  field: detail.field.slice(0, MAX_FIELD_LENGTH),
41
41
  message: detail.message.slice(0, MAX_MESSAGE_LENGTH),
42
- type: detail.type.slice(0, MAX_TYPE_LENGTH),
43
- count: 1
42
+ type: detail.type.slice(0, MAX_TYPE_LENGTH)
44
43
  };
45
- if (consumer !== void 0) group.consumer = consumer.slice(0, MAX_CONSUMER_LENGTH);
46
- const key = [
47
- group.consumer ?? "",
48
- group.method,
49
- group.path,
50
- group.source,
51
- group.field,
52
- group.message,
53
- group.type
54
- ].join("\0");
55
- const existing = validationErrorGroups.get(key);
56
- if (existing) existing.count = Math.min(existing.count + 1, MAX_COUNT);
57
- else if (validationErrorGroups.size < MAX_GROUPS) validationErrorGroups.set(key, group);
44
+ const key = Object.values(fields).join("\0");
45
+ let aggregate = validationErrorAggregates.get(key);
46
+ if (!aggregate) {
47
+ if (validationErrorAggregates.size >= MAX_ERRORS) continue;
48
+ aggregate = {
49
+ ...fields,
50
+ counts: /* @__PURE__ */ new Map()
51
+ };
52
+ validationErrorAggregates.set(key, aggregate);
53
+ }
54
+ aggregate.counts.set(consumer, (aggregate.counts.get(consumer) ?? 0) + 1);
58
55
  }
59
56
  }
60
57
  function drainValidationErrors() {
61
- const groups = [...validationErrorGroups.values()];
62
- validationErrorGroups.clear();
63
- return groups;
58
+ const aggregates = [...validationErrorAggregates.values()];
59
+ validationErrorAggregates.clear();
60
+ return aggregates.map(({ counts, ...fields }) => ({
61
+ ...fields,
62
+ counts: [...counts].map(([consumer, count]) => consumer === void 0 ? { count } : {
63
+ consumer,
64
+ count
65
+ })
66
+ }));
64
67
  }
65
68
  function resetValidationErrors() {
66
- validationErrorGroups.clear();
69
+ validationErrorAggregates.clear();
67
70
  }
68
71
  function normalizeSource(source) {
69
72
  return typeof source === "string" ? SOURCE_ALIASES[source.toLowerCase()] ?? "" : "";
@@ -1 +1 @@
1
- {"version":3,"file":"validationErrors.js","names":[],"sources":["../src/validationErrors.ts"],"sourcesContent":["import { brotliDecompressSync, unzipSync } from \"node:zlib\";\nimport type { AnyValueMap } from \"@opentelemetry/api-logs\";\nimport { MAX_BODY_SIZE } from \"./bodyCapture.js\";\n\nexport const VALIDATION_ERROR_EVENT_NAME = \"apitally.request.validation_error\";\n\nconst MAX_GROUPS = 100;\nconst MAX_CONSUMER_LENGTH = 128;\nconst MAX_PATH_LENGTH = 2_000;\nconst MAX_SOURCE_LENGTH = 32;\nconst MAX_FIELD_LENGTH = 2_048;\nconst MAX_MESSAGE_LENGTH = 2_048;\nconst MAX_TYPE_LENGTH = 128;\nconst MAX_COUNT = 2 ** 32 - 1;\n\nconst SOURCE_ALIASES: Record<string, string> = {\n body: \"body\",\n query: \"query\",\n path: \"path\",\n header: \"header\",\n cookie: \"cookie\",\n querystring: \"query\",\n params: \"path\",\n path_params: \"path\",\n headers: \"header\",\n cookies: \"cookie\",\n};\n\nexport type ValidationErrorDetail = {\n source: string;\n field: string;\n message: string;\n type: string;\n};\n\ntype ValidationErrorGroup = ValidationErrorDetail & {\n consumer?: string;\n method: string;\n path: string;\n count: number;\n};\n\nconst VALIDATION_ERROR_GROUPS_KEY = Symbol.for(\"apitally.validationErrorGroups\");\nconst validationErrorGroupsHolder = globalThis as Record<\n symbol,\n Map<string, ValidationErrorGroup> | undefined\n>;\nconst validationErrorGroups =\n validationErrorGroupsHolder[VALIDATION_ERROR_GROUPS_KEY] ??\n new Map<string, ValidationErrorGroup>();\nvalidationErrorGroupsHolder[VALIDATION_ERROR_GROUPS_KEY] = validationErrorGroups;\n\nexport function isValidationResponseStatus(statusCode: number): boolean {\n return statusCode === 400 || statusCode === 422;\n}\n\nexport function addValidationErrors(\n consumer: string | undefined,\n method: string,\n path: string,\n details: ValidationErrorDetail[],\n): void {\n method = method.toUpperCase();\n if (method === \"OPTIONS\" || !path) {\n return;\n }\n for (const detail of details) {\n const group: ValidationErrorGroup = {\n method,\n path: path.slice(0, MAX_PATH_LENGTH),\n source: detail.source.slice(0, MAX_SOURCE_LENGTH),\n field: detail.field.slice(0, MAX_FIELD_LENGTH),\n message: detail.message.slice(0, MAX_MESSAGE_LENGTH),\n type: detail.type.slice(0, MAX_TYPE_LENGTH),\n count: 1,\n };\n if (consumer !== undefined) {\n group.consumer = consumer.slice(0, MAX_CONSUMER_LENGTH);\n }\n const key = [\n group.consumer ?? \"\",\n group.method,\n group.path,\n group.source,\n group.field,\n group.message,\n group.type,\n ].join(\"\\0\");\n const existing = validationErrorGroups.get(key);\n if (existing) {\n existing.count = Math.min(existing.count + 1, MAX_COUNT);\n } else if (validationErrorGroups.size < MAX_GROUPS) {\n validationErrorGroups.set(key, group);\n }\n }\n}\n\nexport function drainValidationErrors(): AnyValueMap[] {\n const groups = [...validationErrorGroups.values()];\n validationErrorGroups.clear();\n return groups;\n}\n\nexport function resetValidationErrors(): void {\n validationErrorGroups.clear();\n}\n\nexport function normalizeSource(source: unknown): string {\n return typeof source === \"string\" ? (SOURCE_ALIASES[source.toLowerCase()] ?? \"\") : \"\";\n}\n\n// Zod, Standard Schema, and TypeBox issues share this shape. A Standard Schema\n// path segment may be an object carrying the key; a TypeBox path is a JSON pointer.\nexport function formatIssues(issues: unknown, source = \"\"): ValidationErrorDetail[] {\n if (!Array.isArray(issues)) {\n return [];\n }\n const details: ValidationErrorDetail[] = [];\n for (const issue of issues) {\n if (!isRecord(issue) || typeof issue.message !== \"string\") {\n continue;\n }\n const path = Array.isArray(issue.path)\n ? issue.path\n : typeof issue.path === \"string\"\n ? issue.path\n .split(\"/\")\n .filter(Boolean)\n .map((segment) => segment.replace(/~1/g, \"/\").replace(/~0/g, \"~\"))\n : [];\n details.push({\n source,\n field: path\n .map((segment: unknown) => (isRecord(segment) ? segment.key : segment))\n .filter((segment) => typeof segment === \"string\" || typeof segment === \"number\")\n .join(\".\"),\n message: issue.message,\n type: typeof issue.code === \"string\" ? issue.code : \"\",\n });\n }\n return details;\n}\n\n// zod 3 serializes issues as `error.issues`; zod 4 hides them as a JSON string\n// in `error.message`.\nexport function extractZodValidationErrors(body: unknown): ValidationErrorDetail[] {\n if (!isRecord(body)) {\n return [];\n }\n const error = isRecord(body.error) ? body.error : undefined;\n let issues = body.issues ?? error?.issues;\n if (issues === undefined && error?.name === \"ZodError\" && typeof error.message === \"string\") {\n try {\n issues = JSON.parse(error.message);\n } catch {\n return [];\n }\n }\n return formatIssues(issues);\n}\n\n// Recognized response shapes: zod issues, express-validator, and celebrate.\nexport function extractExpressValidationErrors(body: unknown): ValidationErrorDetail[] {\n if (!isRecord(body)) {\n return [];\n }\n const zodDetails = extractZodValidationErrors(body);\n if (zodDetails.length > 0) {\n return zodDetails;\n }\n if (Array.isArray(body.errors)) {\n return extractExpressValidatorErrors(body.errors);\n }\n if (isRecord(body.validation)) {\n return extractCelebrateErrors(body.validation);\n }\n return [];\n}\n\nfunction extractExpressValidatorErrors(errors: unknown[]): ValidationErrorDetail[] {\n const details: ValidationErrorDetail[] = [];\n for (const error of errors) {\n if (\n !isRecord(error) ||\n error.type !== \"field\" ||\n typeof error.path !== \"string\" ||\n typeof error.msg !== \"string\"\n ) {\n continue;\n }\n const source = normalizeSource(error.location);\n if (source) {\n details.push({ source, field: error.path, message: error.msg, type: \"\" });\n }\n }\n return details;\n}\n\n// Celebrate joins the messages of all failed keys into one sentence list, so\n// each key takes the sentence naming it.\nfunction extractCelebrateErrors(validation: Record<string, unknown>): ValidationErrorDetail[] {\n const details: ValidationErrorDetail[] = [];\n for (const error of Object.values(validation)) {\n if (!isRecord(error) || !Array.isArray(error.keys) || typeof error.message !== \"string\") {\n continue;\n }\n const sentences = error.message.split(\". \");\n for (const key of error.keys) {\n if (typeof key === \"string\") {\n details.push({\n source: normalizeSource(error.source),\n field: key,\n message: sentences.find((sentence) => sentence.includes(`\"${key}\"`)) ?? error.message,\n type: \"\",\n });\n }\n }\n }\n return details;\n}\n\nexport function parseJsonResponseBody(body: Buffer | undefined, contentEncoding: unknown): unknown {\n if (!body) {\n return undefined;\n }\n try {\n const encoding =\n typeof contentEncoding === \"string\" ? contentEncoding.trim().toLowerCase() : \"\";\n let decoded = body;\n if (encoding === \"br\") {\n decoded = brotliDecompressSync(body, { maxOutputLength: MAX_BODY_SIZE });\n } else if (encoding === \"gzip\" || encoding === \"deflate\") {\n decoded = unzipSync(body, { maxOutputLength: MAX_BODY_SIZE });\n } else if (encoding !== \"\" && encoding !== \"identity\") {\n return undefined;\n }\n return JSON.parse(decoded.toString());\n } catch {\n // A body that cannot be decoded as JSON carries no recognizable validation details.\n }\n return undefined;\n}\n\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"validationErrors.js","names":[],"sources":["../src/validationErrors.ts"],"sourcesContent":["import { brotliDecompressSync, unzipSync } from \"node:zlib\";\nimport type { AnyValueMap } from \"@opentelemetry/api-logs\";\nimport { MAX_BODY_SIZE } from \"./bodyCapture.js\";\n\nexport const VALIDATION_ERROR_EVENT_NAME = \"apitally.request.validation_error\";\n\nconst MAX_ERRORS = 100;\nconst MAX_CONSUMER_LENGTH = 128;\nconst MAX_PATH_LENGTH = 2_000;\nconst MAX_SOURCE_LENGTH = 32;\nconst MAX_FIELD_LENGTH = 2_048;\nconst MAX_MESSAGE_LENGTH = 2_048;\nconst MAX_TYPE_LENGTH = 128;\n\nconst SOURCE_ALIASES: Record<string, string> = {\n body: \"body\",\n query: \"query\",\n path: \"path\",\n header: \"header\",\n cookie: \"cookie\",\n querystring: \"query\",\n params: \"path\",\n path_params: \"path\",\n headers: \"header\",\n cookies: \"cookie\",\n};\n\nexport type ValidationErrorDetail = {\n source: string;\n field: string;\n message: string;\n type: string;\n};\n\ntype ValidationErrorAggregate = ValidationErrorDetail & {\n method: string;\n path: string;\n counts: Map<string | undefined, number>;\n};\n\nconst VALIDATION_ERROR_AGGREGATES_KEY = Symbol.for(\"apitally.validationErrorAggregates\");\nconst validationErrorAggregatesHolder = globalThis as Record<\n symbol,\n Map<string, ValidationErrorAggregate> | undefined\n>;\nconst validationErrorAggregates =\n validationErrorAggregatesHolder[VALIDATION_ERROR_AGGREGATES_KEY] ??\n new Map<string, ValidationErrorAggregate>();\nvalidationErrorAggregatesHolder[VALIDATION_ERROR_AGGREGATES_KEY] = validationErrorAggregates;\n\nexport function isValidationResponseStatus(statusCode: number): boolean {\n return statusCode === 400 || statusCode === 422;\n}\n\nexport function addValidationErrors(\n consumer: string | undefined,\n method: string,\n path: string,\n details: ValidationErrorDetail[],\n): void {\n method = method.toUpperCase();\n if (method === \"OPTIONS\" || !path) {\n return;\n }\n consumer = consumer?.slice(0, MAX_CONSUMER_LENGTH);\n for (const detail of details) {\n const fields = {\n method,\n path: path.slice(0, MAX_PATH_LENGTH),\n source: detail.source.slice(0, MAX_SOURCE_LENGTH),\n field: detail.field.slice(0, MAX_FIELD_LENGTH),\n message: detail.message.slice(0, MAX_MESSAGE_LENGTH),\n type: detail.type.slice(0, MAX_TYPE_LENGTH),\n };\n const key = Object.values(fields).join(\"\\0\");\n let aggregate = validationErrorAggregates.get(key);\n if (!aggregate) {\n if (validationErrorAggregates.size >= MAX_ERRORS) {\n continue;\n }\n aggregate = { ...fields, counts: new Map() };\n validationErrorAggregates.set(key, aggregate);\n }\n aggregate.counts.set(consumer, (aggregate.counts.get(consumer) ?? 0) + 1);\n }\n}\n\nexport function drainValidationErrors(): AnyValueMap[] {\n const aggregates = [...validationErrorAggregates.values()];\n validationErrorAggregates.clear();\n return aggregates.map(({ counts, ...fields }) => ({\n ...fields,\n counts: [...counts].map(([consumer, count]) =>\n consumer === undefined ? { count } : { consumer, count },\n ),\n }));\n}\n\nexport function resetValidationErrors(): void {\n validationErrorAggregates.clear();\n}\n\nexport function normalizeSource(source: unknown): string {\n return typeof source === \"string\" ? (SOURCE_ALIASES[source.toLowerCase()] ?? \"\") : \"\";\n}\n\n// Zod, Standard Schema, and TypeBox issues share this shape. A Standard Schema\n// path segment may be an object carrying the key; a TypeBox path is a JSON pointer.\nexport function formatIssues(issues: unknown, source = \"\"): ValidationErrorDetail[] {\n if (!Array.isArray(issues)) {\n return [];\n }\n const details: ValidationErrorDetail[] = [];\n for (const issue of issues) {\n if (!isRecord(issue) || typeof issue.message !== \"string\") {\n continue;\n }\n const path = Array.isArray(issue.path)\n ? issue.path\n : typeof issue.path === \"string\"\n ? issue.path\n .split(\"/\")\n .filter(Boolean)\n .map((segment) => segment.replace(/~1/g, \"/\").replace(/~0/g, \"~\"))\n : [];\n details.push({\n source,\n field: path\n .map((segment: unknown) => (isRecord(segment) ? segment.key : segment))\n .filter((segment) => typeof segment === \"string\" || typeof segment === \"number\")\n .join(\".\"),\n message: issue.message,\n type: typeof issue.code === \"string\" ? issue.code : \"\",\n });\n }\n return details;\n}\n\n// zod 3 serializes issues as `error.issues`; zod 4 hides them as a JSON string\n// in `error.message`.\nexport function extractZodValidationErrors(body: unknown): ValidationErrorDetail[] {\n if (!isRecord(body)) {\n return [];\n }\n const error = isRecord(body.error) ? body.error : undefined;\n let issues = body.issues ?? error?.issues;\n if (issues === undefined && error?.name === \"ZodError\" && typeof error.message === \"string\") {\n try {\n issues = JSON.parse(error.message);\n } catch {\n return [];\n }\n }\n return formatIssues(issues);\n}\n\n// Recognized response shapes: zod issues, express-validator, and celebrate.\nexport function extractExpressValidationErrors(body: unknown): ValidationErrorDetail[] {\n if (!isRecord(body)) {\n return [];\n }\n const zodDetails = extractZodValidationErrors(body);\n if (zodDetails.length > 0) {\n return zodDetails;\n }\n if (Array.isArray(body.errors)) {\n return extractExpressValidatorErrors(body.errors);\n }\n if (isRecord(body.validation)) {\n return extractCelebrateErrors(body.validation);\n }\n return [];\n}\n\nfunction extractExpressValidatorErrors(errors: unknown[]): ValidationErrorDetail[] {\n const details: ValidationErrorDetail[] = [];\n for (const error of errors) {\n if (\n !isRecord(error) ||\n error.type !== \"field\" ||\n typeof error.path !== \"string\" ||\n typeof error.msg !== \"string\"\n ) {\n continue;\n }\n const source = normalizeSource(error.location);\n if (source) {\n details.push({ source, field: error.path, message: error.msg, type: \"\" });\n }\n }\n return details;\n}\n\n// Celebrate joins the messages of all failed keys into one sentence list, so\n// each key takes the sentence naming it.\nfunction extractCelebrateErrors(validation: Record<string, unknown>): ValidationErrorDetail[] {\n const details: ValidationErrorDetail[] = [];\n for (const error of Object.values(validation)) {\n if (!isRecord(error) || !Array.isArray(error.keys) || typeof error.message !== \"string\") {\n continue;\n }\n const sentences = error.message.split(\". \");\n for (const key of error.keys) {\n if (typeof key === \"string\") {\n details.push({\n source: normalizeSource(error.source),\n field: key,\n message: sentences.find((sentence) => sentence.includes(`\"${key}\"`)) ?? error.message,\n type: \"\",\n });\n }\n }\n }\n return details;\n}\n\nexport function parseJsonResponseBody(body: Buffer | undefined, contentEncoding: unknown): unknown {\n if (!body) {\n return undefined;\n }\n try {\n const encoding =\n typeof contentEncoding === \"string\" ? contentEncoding.trim().toLowerCase() : \"\";\n let decoded = body;\n if (encoding === \"br\") {\n decoded = brotliDecompressSync(body, { maxOutputLength: MAX_BODY_SIZE });\n } else if (encoding === \"gzip\" || encoding === \"deflate\") {\n decoded = unzipSync(body, { maxOutputLength: MAX_BODY_SIZE });\n } else if (encoding !== \"\" && encoding !== \"identity\") {\n return undefined;\n }\n return JSON.parse(decoded.toString());\n } catch {\n // A body that cannot be decoded as JSON carries no recognizable validation details.\n }\n return undefined;\n}\n\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null;\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apitally",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.4",
4
4
  "description": "Simple API monitoring, analytics and request logging for REST APIs built with AdonisJS, Elysia, Express, Fastify, H3, Hapi, Hono, Koa and NestJS.",
5
5
  "author": "Apitally <hello@apitally.io>",
6
6
  "license": "MIT",
@@ -289,7 +289,7 @@
289
289
  "elysia": "^1.4.29",
290
290
  "express": "^5.2.1",
291
291
  "fastify": "^5.11.0",
292
- "h3": "2.0.1-rc.31",
292
+ "h3": "2.0.1-rc.32",
293
293
  "hono": "^4.12.31",
294
294
  "knip": "^6.29.0",
295
295
  "koa": "^3.2.1",