@futurewindai/wotchi 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +128 -0
  3. package/SECURITY.md +17 -0
  4. package/dist/cjs/core/client.js +154 -0
  5. package/dist/cjs/core/config.js +114 -0
  6. package/dist/cjs/core/diagnostics.js +14 -0
  7. package/dist/cjs/core/errors.js +11 -0
  8. package/dist/cjs/core/fingerprint.js +37 -0
  9. package/dist/cjs/core/group-store.js +95 -0
  10. package/dist/cjs/core/incident-builder.js +46 -0
  11. package/dist/cjs/core/incident-policy.js +31 -0
  12. package/dist/cjs/core/normalize.js +138 -0
  13. package/dist/cjs/core/notification-queue.js +103 -0
  14. package/dist/cjs/core/process-monitor.js +30 -0
  15. package/dist/cjs/core/redact.js +141 -0
  16. package/dist/cjs/core/rolling-window.js +60 -0
  17. package/dist/cjs/core/stack-frame.js +33 -0
  18. package/dist/cjs/core/types.js +2 -0
  19. package/dist/cjs/index.js +13 -0
  20. package/dist/cjs/integrations/express/error-handler.js +15 -0
  21. package/dist/cjs/integrations/express/index.js +12 -0
  22. package/dist/cjs/integrations/express/request-context.js +19 -0
  23. package/dist/cjs/integrations/nest/exception-filter.js +36 -0
  24. package/dist/cjs/integrations/nest/index.js +9 -0
  25. package/dist/cjs/integrations/nest/register.js +13 -0
  26. package/dist/cjs/integrations/nest/request-context.js +41 -0
  27. package/dist/cjs/integrations/request-context.js +76 -0
  28. package/dist/cjs/notifiers/console.js +33 -0
  29. package/dist/cjs/notifiers/telegram-format.js +52 -0
  30. package/dist/cjs/notifiers/telegram-http.js +150 -0
  31. package/dist/cjs/notifiers/telegram.js +38 -0
  32. package/dist/cjs/package.json +1 -0
  33. package/dist/esm/core/client.js +151 -0
  34. package/dist/esm/core/config.js +110 -0
  35. package/dist/esm/core/diagnostics.js +10 -0
  36. package/dist/esm/core/errors.js +7 -0
  37. package/dist/esm/core/fingerprint.js +33 -0
  38. package/dist/esm/core/group-store.js +92 -0
  39. package/dist/esm/core/incident-builder.js +43 -0
  40. package/dist/esm/core/incident-policy.js +28 -0
  41. package/dist/esm/core/normalize.js +134 -0
  42. package/dist/esm/core/notification-queue.js +100 -0
  43. package/dist/esm/core/process-monitor.js +27 -0
  44. package/dist/esm/core/redact.js +136 -0
  45. package/dist/esm/core/rolling-window.js +57 -0
  46. package/dist/esm/core/stack-frame.js +29 -0
  47. package/dist/esm/core/types.js +1 -0
  48. package/dist/esm/index.js +6 -0
  49. package/dist/esm/integrations/express/error-handler.js +12 -0
  50. package/dist/esm/integrations/express/index.js +5 -0
  51. package/dist/esm/integrations/express/request-context.js +16 -0
  52. package/dist/esm/integrations/nest/exception-filter.js +33 -0
  53. package/dist/esm/integrations/nest/index.js +2 -0
  54. package/dist/esm/integrations/nest/register.js +10 -0
  55. package/dist/esm/integrations/nest/request-context.js +38 -0
  56. package/dist/esm/integrations/request-context.js +72 -0
  57. package/dist/esm/notifiers/console.js +29 -0
  58. package/dist/esm/notifiers/telegram-format.js +49 -0
  59. package/dist/esm/notifiers/telegram-http.js +144 -0
  60. package/dist/esm/notifiers/telegram.js +34 -0
  61. package/dist/types/core/client.d.ts +2 -0
  62. package/dist/types/core/config.d.ts +29 -0
  63. package/dist/types/core/diagnostics.d.ts +7 -0
  64. package/dist/types/core/errors.d.ts +3 -0
  65. package/dist/types/core/fingerprint.d.ts +3 -0
  66. package/dist/types/core/group-store.d.ts +15 -0
  67. package/dist/types/core/incident-builder.d.ts +3 -0
  68. package/dist/types/core/incident-policy.d.ts +15 -0
  69. package/dist/types/core/normalize.d.ts +17 -0
  70. package/dist/types/core/notification-queue.d.ts +16 -0
  71. package/dist/types/core/process-monitor.d.ts +5 -0
  72. package/dist/types/core/redact.d.ts +11 -0
  73. package/dist/types/core/rolling-window.d.ts +12 -0
  74. package/dist/types/core/stack-frame.d.ts +2 -0
  75. package/dist/types/core/types.d.ts +109 -0
  76. package/dist/types/index.d.ts +9 -0
  77. package/dist/types/integrations/express/error-handler.d.ts +4 -0
  78. package/dist/types/integrations/express/index.d.ts +7 -0
  79. package/dist/types/integrations/express/request-context.d.ts +5 -0
  80. package/dist/types/integrations/nest/exception-filter.d.ts +10 -0
  81. package/dist/types/integrations/nest/index.d.ts +4 -0
  82. package/dist/types/integrations/nest/register.d.ts +10 -0
  83. package/dist/types/integrations/nest/request-context.d.ts +5 -0
  84. package/dist/types/integrations/request-context.d.ts +13 -0
  85. package/dist/types/notifiers/console.d.ts +3 -0
  86. package/dist/types/notifiers/telegram-format.d.ts +2 -0
  87. package/dist/types/notifiers/telegram-http.d.ts +21 -0
  88. package/dist/types/notifiers/telegram.d.ts +4 -0
  89. package/package.json +111 -0
@@ -0,0 +1,151 @@
1
+ import { validateConfig } from "./config.js";
2
+ import { createDiagnosticsState, snapshotDiagnostics } from "./diagnostics.js";
3
+ import { fingerprintSafeErrorEvent } from "./fingerprint.js";
4
+ import { createGroupStore } from "./group-store.js";
5
+ import { buildIncidentAlert } from "./incident-builder.js";
6
+ import { evaluateIncidentPolicy } from "./incident-policy.js";
7
+ import { normalizeError, normalizeUnknown } from "./normalize.js";
8
+ import { createNotificationQueue } from "./notification-queue.js";
9
+ import { redactError, redactValue } from "./redact.js";
10
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
11
+ const asString = (value) => typeof value === "string" ? value : undefined;
12
+ const asStatusCode = (value) => typeof value === "number" && Number.isSafeInteger(value) ? value : undefined;
13
+ const safeRecord = (value, privacy) => {
14
+ if (value === undefined) {
15
+ return undefined;
16
+ }
17
+ const redacted = redactValue(value, privacy);
18
+ return isRecord(redacted) ? redacted : undefined;
19
+ };
20
+ const mergeContext = (metadata, context, privacy) => {
21
+ if (metadata === undefined && context === undefined) {
22
+ return undefined;
23
+ }
24
+ const metadataValue = safeRecord(metadata, privacy);
25
+ const contextValue = safeRecord(context, privacy);
26
+ if (metadataValue === undefined && contextValue === undefined) {
27
+ return undefined;
28
+ }
29
+ return { ...(metadataValue ?? {}), ...(contextValue ?? {}) };
30
+ };
31
+ const safeRequest = (request, privacy) => {
32
+ if (request === undefined) {
33
+ return undefined;
34
+ }
35
+ const redacted = redactValue(request, privacy);
36
+ if (!isRecord(redacted)) {
37
+ return undefined;
38
+ }
39
+ const method = asString(redacted.method);
40
+ const route = asString(redacted.route);
41
+ const requestId = asString(redacted.requestId);
42
+ const statusCode = asStatusCode(redacted.statusCode);
43
+ if (method === undefined &&
44
+ route === undefined &&
45
+ requestId === undefined &&
46
+ statusCode === undefined) {
47
+ return undefined;
48
+ }
49
+ return {
50
+ ...(method === undefined ? {} : { method }),
51
+ ...(route === undefined ? {} : { route }),
52
+ ...(requestId === undefined ? {} : { requestId }),
53
+ ...(statusCode === undefined ? {} : { statusCode }),
54
+ };
55
+ };
56
+ const safeMessage = (value, privacy) => {
57
+ const normalized = normalizeUnknown(value, privacy);
58
+ return typeof normalized === "string" ? normalized : "[unreadable value]";
59
+ };
60
+ export function createWotchi(config) {
61
+ const normalized = validateConfig(config);
62
+ const now = Date.now;
63
+ const groupStore = createGroupStore({
64
+ maxGroups: normalized.grouping.maxGroups,
65
+ maxEventsPerWindow: normalized.grouping.maxEventsPerWindow,
66
+ windowMs: normalized.grouping.windowMs,
67
+ now,
68
+ });
69
+ const diagnostics = createDiagnosticsState();
70
+ const queue = createNotificationQueue({
71
+ maxPendingAlerts: normalized.queue.maxPendingAlerts,
72
+ concurrency: normalized.queue.concurrency,
73
+ });
74
+ const privacy = normalized.privacy;
75
+ let eventSequence = 0;
76
+ const captureSafeEvent = (error, metadata, context, request, eventKind = "error") => {
77
+ const timestamp = now();
78
+ const safeError = redactError(normalizeError(error, privacy), privacy);
79
+ const requestContext = safeRequest(request, privacy);
80
+ const eventContext = mergeContext(metadata, context, privacy);
81
+ const safeEvent = {
82
+ id: `event-${eventSequence + 1}`,
83
+ timestamp: new Date(timestamp).toISOString(),
84
+ service: normalized.service,
85
+ environment: normalized.environment,
86
+ ...(normalized.release === undefined ? {} : { release: normalized.release }),
87
+ error: safeError,
88
+ ...(requestContext === undefined ? {} : { request: requestContext }),
89
+ ...(eventContext === undefined ? {} : { context: eventContext }),
90
+ };
91
+ eventSequence += 1;
92
+ const fingerprint = fingerprintSafeErrorEvent(safeEvent);
93
+ const group = groupStore.record(fingerprint, safeEvent);
94
+ const decision = evaluateIncidentPolicy({
95
+ group,
96
+ now: timestamp,
97
+ alertThreshold: normalized.grouping.alertThreshold,
98
+ cooldownMs: normalized.grouping.cooldownMs,
99
+ eventKind,
100
+ });
101
+ if (decision.shouldAlert) {
102
+ const alert = buildIncidentAlert(group, decision);
103
+ if (queue.enqueue(alert, normalized.notifiers)) {
104
+ groupStore.markAlerted(fingerprint, timestamp);
105
+ }
106
+ }
107
+ diagnostics.capturedEvents += 1;
108
+ };
109
+ const captureException = (error, context) => {
110
+ if (!normalized.enabled) {
111
+ return;
112
+ }
113
+ try {
114
+ captureSafeEvent(error, undefined, context, undefined);
115
+ }
116
+ catch {
117
+ diagnostics.captureFailures += 1;
118
+ }
119
+ };
120
+ const captureEvent = (event) => {
121
+ if (!normalized.enabled) {
122
+ return;
123
+ }
124
+ try {
125
+ if (event === null || typeof event !== "object" || event.level !== "error") {
126
+ throw new TypeError("event.level must be error");
127
+ }
128
+ if (typeof event.message !== "string") {
129
+ throw new TypeError("event.message must be a string");
130
+ }
131
+ captureSafeEvent(event.error ?? safeMessage(event.message, privacy), event.metadata, event.context, event.request, event.kind ?? "error");
132
+ }
133
+ catch {
134
+ diagnostics.captureFailures += 1;
135
+ }
136
+ };
137
+ return {
138
+ captureException,
139
+ captureEvent,
140
+ flush: (timeoutMs) => queue.flush(timeoutMs),
141
+ getDiagnostics: () => snapshotDiagnostics(diagnostics, {
142
+ groupsEvicted: groupStore.groupsEvicted(),
143
+ alertsQueued: queue.alertsQueued(),
144
+ alertsDropped: queue.alertsDropped(),
145
+ alertsSent: queue.alertsSent(),
146
+ notifierFailures: queue.notifierFailures(),
147
+ activeGroups: groupStore.size(),
148
+ pendingAlerts: queue.pending(),
149
+ }),
150
+ };
151
+ }
@@ -0,0 +1,110 @@
1
+ import { WotchiConfigurationError } from "./errors.js";
2
+ export { WotchiConfigurationError };
3
+ const MAX_REDACT_KEYS = 100;
4
+ const isRecord = (value) => typeof value === "object" && value !== null;
5
+ const fail = (field) => {
6
+ throw new WotchiConfigurationError(`${field} is invalid`);
7
+ };
8
+ const readPositiveInteger = (parent, field, defaultValue) => {
9
+ const value = parent?.[field];
10
+ if (value === undefined) {
11
+ return defaultValue;
12
+ }
13
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
14
+ return fail(field);
15
+ }
16
+ return value;
17
+ };
18
+ const readOptionalRecord = (value, field) => {
19
+ if (value === undefined) {
20
+ return undefined;
21
+ }
22
+ if (!isRecord(value)) {
23
+ return fail(field);
24
+ }
25
+ return value;
26
+ };
27
+ const normalizeRedactKeys = (privacy) => {
28
+ const value = privacy?.redactKeys;
29
+ if (value === undefined) {
30
+ return Object.freeze([]);
31
+ }
32
+ if (!Array.isArray(value) || value.length > MAX_REDACT_KEYS) {
33
+ return fail("privacy.redactKeys");
34
+ }
35
+ const keys = value.map((key, index) => {
36
+ if (typeof key !== "string" || key.trim().length === 0) {
37
+ return fail(`privacy.redactKeys[${index}]`);
38
+ }
39
+ return key.trim();
40
+ });
41
+ return Object.freeze([...new Set(keys)]);
42
+ };
43
+ const normalizeNotifiers = (value) => {
44
+ if (!Array.isArray(value) || value.length === 0) {
45
+ return fail("notifiers");
46
+ }
47
+ for (let index = 0; index < value.length; index += 1) {
48
+ const notifier = value[index];
49
+ if (!isRecord(notifier) ||
50
+ typeof notifier.name !== "string" ||
51
+ notifier.name.trim().length === 0 ||
52
+ typeof notifier.send !== "function") {
53
+ return fail(`notifiers[${index}]`);
54
+ }
55
+ }
56
+ return Object.freeze([...value]);
57
+ };
58
+ export function validateConfig(config) {
59
+ if (!isRecord(config)) {
60
+ return fail("config");
61
+ }
62
+ const service = config.service;
63
+ if (typeof service !== "string" || service.trim().length === 0) {
64
+ return fail("service");
65
+ }
66
+ const environment = config.environment;
67
+ if (typeof environment !== "string" || environment.trim().length === 0) {
68
+ return fail("environment");
69
+ }
70
+ if (config.release !== undefined &&
71
+ (typeof config.release !== "string" || config.release.trim().length === 0)) {
72
+ return fail("release");
73
+ }
74
+ if (config.enabled !== undefined && typeof config.enabled !== "boolean") {
75
+ return fail("enabled");
76
+ }
77
+ const grouping = readOptionalRecord(config.grouping, "grouping");
78
+ const queue = readOptionalRecord(config.queue, "queue");
79
+ const privacy = readOptionalRecord(config.privacy, "privacy");
80
+ const concurrency = queue?.concurrency;
81
+ if (concurrency !== undefined && concurrency !== 1) {
82
+ return fail("queue.concurrency");
83
+ }
84
+ const normalized = {
85
+ service: service.trim(),
86
+ environment: environment.trim(),
87
+ ...(config.release === undefined ? {} : { release: config.release.trim() }),
88
+ enabled: config.enabled ?? true,
89
+ grouping: Object.freeze({
90
+ windowMs: readPositiveInteger(grouping, "windowMs", 60_000),
91
+ alertThreshold: readPositiveInteger(grouping, "alertThreshold", 3),
92
+ cooldownMs: readPositiveInteger(grouping, "cooldownMs", 900_000),
93
+ maxGroups: readPositiveInteger(grouping, "maxGroups", 200),
94
+ maxEventsPerWindow: readPositiveInteger(grouping, "maxEventsPerWindow", 100),
95
+ }),
96
+ queue: Object.freeze({
97
+ maxPendingAlerts: readPositiveInteger(queue, "maxPendingAlerts", 100),
98
+ concurrency: 1,
99
+ }),
100
+ privacy: Object.freeze({
101
+ redactKeys: normalizeRedactKeys(privacy),
102
+ maxDepth: readPositiveInteger(privacy, "maxDepth", 5),
103
+ maxKeys: readPositiveInteger(privacy, "maxKeys", 100),
104
+ maxStringLength: readPositiveInteger(privacy, "maxStringLength", 500),
105
+ maxStackLength: readPositiveInteger(privacy, "maxStackLength", 4_000),
106
+ }),
107
+ notifiers: normalizeNotifiers(config.notifiers),
108
+ };
109
+ return Object.freeze(normalized);
110
+ }
@@ -0,0 +1,10 @@
1
+ export function createDiagnosticsState() {
2
+ return { capturedEvents: 0, captureFailures: 0 };
3
+ }
4
+ export function snapshotDiagnostics(state, values) {
5
+ return Object.freeze({
6
+ capturedEvents: state.capturedEvents,
7
+ captureFailures: state.captureFailures,
8
+ ...values,
9
+ });
10
+ }
@@ -0,0 +1,7 @@
1
+ export class WotchiConfigurationError extends Error {
2
+ constructor(message) {
3
+ super(`Invalid Wotchi configuration: ${message}`);
4
+ this.name = "WotchiConfigurationError";
5
+ Object.setPrototypeOf(this, new.target.prototype);
6
+ }
7
+ }
@@ -0,0 +1,33 @@
1
+ import { createHash } from "node:crypto";
2
+ import { redactValue } from "./redact.js";
3
+ import { selectApplicationFrame } from "./stack-frame.js";
4
+ const UUID_PATTERN = /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi;
5
+ const HEX_PATTERN = /\b(?:0x)?[0-9a-f]{8,}\b/gi;
6
+ const NUMBER_PATTERN = /\b\d+\b/g;
7
+ export function normalizeDynamicSegments(value) {
8
+ return value
9
+ .replace(UUID_PATTERN, "<uuid>")
10
+ .replace(HEX_PATTERN, "<hex>")
11
+ .replace(NUMBER_PATTERN, "<number>")
12
+ .replace(/\s+/g, " ")
13
+ .trim()
14
+ .toLowerCase();
15
+ }
16
+ const safeText = (value) => {
17
+ const redacted = redactValue(value, { maxStringLength: 4_000 });
18
+ return typeof redacted === "string" ? redacted : "[unreadable value]";
19
+ };
20
+ const encodePart = (value) => `${value.length}:${value}`;
21
+ export function fingerprintSafeErrorEvent(event) {
22
+ const service = normalizeDynamicSegments(safeText(event.service));
23
+ const environment = normalizeDynamicSegments(safeText(event.environment));
24
+ const errorName = normalizeDynamicSegments(safeText(event.error.name));
25
+ const message = normalizeDynamicSegments(safeText(event.error.message));
26
+ const method = normalizeDynamicSegments(safeText(event.request?.method ?? ""));
27
+ const route = normalizeDynamicSegments(safeText(event.request?.route ?? ""));
28
+ const frame = normalizeDynamicSegments(selectApplicationFrame(event.error.stack) ?? "");
29
+ const canonical = [service, environment, errorName, message, method, route, frame]
30
+ .map(encodePart)
31
+ .join("|");
32
+ return createHash("sha256").update(canonical, "utf8").digest("hex");
33
+ }
@@ -0,0 +1,92 @@
1
+ import { normalizeUnknown } from "./normalize.js";
2
+ import { createRollingWindow } from "./rolling-window.js";
3
+ const cloneSafeEvent = (event) => {
4
+ const context = event.context === undefined ? undefined : normalizeUnknown(event.context);
5
+ return {
6
+ id: event.id,
7
+ timestamp: event.timestamp,
8
+ service: event.service,
9
+ environment: event.environment,
10
+ ...(event.release === undefined ? {} : { release: event.release }),
11
+ error: { ...event.error },
12
+ ...(event.request === undefined ? {} : { request: { ...event.request } }),
13
+ ...(context === undefined ? {} : { context: context }),
14
+ };
15
+ };
16
+ export function createGroupStore(options) {
17
+ if (!Number.isSafeInteger(options.maxGroups) || options.maxGroups <= 0) {
18
+ throw new RangeError("maxGroups must be a positive integer");
19
+ }
20
+ const now = options.now ?? Date.now;
21
+ const groups = new Map();
22
+ let evicted = 0;
23
+ const snapshot = (group) => ({
24
+ fingerprint: group.fingerprint,
25
+ firstSeenAt: new Date(group.firstSeenMs).toISOString(),
26
+ lastSeenAt: new Date(group.lastSeenMs).toISOString(),
27
+ totalCount: group.totalCount,
28
+ windowCount: group.window.count(group.lastSeenMs),
29
+ sample: cloneSafeEvent(group.sample),
30
+ ...(group.lastAlertedMs === undefined
31
+ ? {}
32
+ : { lastAlertedAt: new Date(group.lastAlertedMs).toISOString() }),
33
+ severity: group.severity,
34
+ });
35
+ const evictLeastRecent = () => {
36
+ let candidate;
37
+ for (const group of groups.values()) {
38
+ if (candidate === undefined || group.lastSeenMs < candidate.lastSeenMs) {
39
+ candidate = group;
40
+ }
41
+ }
42
+ if (candidate !== undefined) {
43
+ groups.delete(candidate.fingerprint);
44
+ evicted += 1;
45
+ }
46
+ };
47
+ const record = (fingerprint, event, severity = "medium") => {
48
+ const timestamp = now();
49
+ let group = groups.get(fingerprint);
50
+ if (group === undefined) {
51
+ if (groups.size >= options.maxGroups) {
52
+ evictLeastRecent();
53
+ }
54
+ group = {
55
+ fingerprint,
56
+ firstSeenMs: timestamp,
57
+ lastSeenMs: timestamp,
58
+ totalCount: 0,
59
+ sample: cloneSafeEvent(event),
60
+ severity,
61
+ window: createRollingWindow({
62
+ maxEvents: options.maxEventsPerWindow,
63
+ windowMs: options.windowMs,
64
+ now,
65
+ }),
66
+ };
67
+ groups.set(fingerprint, group);
68
+ }
69
+ group.lastSeenMs = timestamp;
70
+ group.totalCount += 1;
71
+ group.severity = severity;
72
+ group.window.add(timestamp);
73
+ return snapshot(group);
74
+ };
75
+ return {
76
+ record,
77
+ get: (fingerprint) => {
78
+ const group = groups.get(fingerprint);
79
+ return group === undefined ? undefined : snapshot(group);
80
+ },
81
+ markAlerted: (fingerprint, timestamp = now()) => {
82
+ const group = groups.get(fingerprint);
83
+ if (group === undefined) {
84
+ return undefined;
85
+ }
86
+ group.lastAlertedMs = timestamp;
87
+ return snapshot(group);
88
+ },
89
+ size: () => groups.size,
90
+ groupsEvicted: () => evicted,
91
+ };
92
+ }
@@ -0,0 +1,43 @@
1
+ const titleCase = (value) => `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
2
+ const suggestedActions = (group) => {
3
+ const text = `${group.sample.error.name} ${group.sample.error.message}`.toLowerCase();
4
+ if (text.includes("database") || text.includes("query") || text.includes("sql")) {
5
+ return [
6
+ "Check database availability, connection saturation, and recent schema changes.",
7
+ "Check the affected query and its dependency health before increasing capacity.",
8
+ ];
9
+ }
10
+ if (text.includes("timeout") || text.includes("timed out")) {
11
+ return [
12
+ "Check upstream latency, timeout settings, and recent dependency changes.",
13
+ "Check whether retries or a dependency outage are increasing request pressure.",
14
+ ];
15
+ }
16
+ if (text.includes("auth") || text.includes("permission") || text.includes("unauthor")) {
17
+ return [
18
+ "Check authentication configuration, credentials, and recent access-policy changes.",
19
+ "Check whether the affected route is receiving an unexpected caller or token.",
20
+ ];
21
+ }
22
+ return [
23
+ "Check the application logs and the selected stack frame for the first failing operation.",
24
+ "Check recent deployments, dependency health, and the affected request path.",
25
+ ];
26
+ };
27
+ export function buildIncidentAlert(group, decision) {
28
+ const message = group.sample.error.message;
29
+ const errorName = group.sample.error.name;
30
+ return {
31
+ id: `wotchi-${group.fingerprint}-${group.lastSeenAt}`,
32
+ fingerprint: group.fingerprint,
33
+ title: `Wotchi — ${titleCase(decision.severity)} incident`,
34
+ severity: decision.severity,
35
+ summary: `Observed ${group.windowCount} occurrences of ${errorName}: ${message}.`,
36
+ suggestedActions: suggestedActions(group),
37
+ firstSeenAt: group.firstSeenAt,
38
+ lastSeenAt: group.lastSeenAt,
39
+ occurrences: group.windowCount,
40
+ service: group.sample.service,
41
+ environment: group.sample.environment,
42
+ };
43
+ }
@@ -0,0 +1,28 @@
1
+ const hasCooldown = (group, now, cooldownMs) => {
2
+ if (group.lastAlertedAt === undefined) {
3
+ return false;
4
+ }
5
+ const lastAlerted = Date.parse(group.lastAlertedAt);
6
+ return Number.isFinite(lastAlerted) && now - lastAlerted < cooldownMs;
7
+ };
8
+ const hasUnavailableResponse = (group) => group.sample.request?.statusCode === 503;
9
+ export function evaluateIncidentPolicy(input) {
10
+ const eventKind = input.eventKind ?? "error";
11
+ const isCritical = eventKind === "process-monitor";
12
+ const isHigh = input.group.windowCount >= 20 || hasUnavailableResponse(input.group);
13
+ const severity = isCritical
14
+ ? "critical"
15
+ : isHigh
16
+ ? "high"
17
+ : eventKind === "manual"
18
+ ? (input.manualSeverity ?? "low")
19
+ : "medium";
20
+ const eligible = isCritical ||
21
+ isHigh ||
22
+ eventKind === "manual" ||
23
+ input.group.windowCount >= input.alertThreshold;
24
+ return {
25
+ shouldAlert: eligible && !hasCooldown(input.group, input.now, input.cooldownMs),
26
+ severity,
27
+ };
28
+ }
@@ -0,0 +1,134 @@
1
+ const UNREADABLE_VALUE = "[unreadable value]";
2
+ const MAX_DEPTH_VALUE = "[MaxDepth]";
3
+ const CIRCULAR_VALUE = "[Circular]";
4
+ const MAX_KEYS_VALUE = "[MaxKeys]";
5
+ const defaultLimits = {
6
+ maxDepth: 5,
7
+ maxKeys: 100,
8
+ maxStringLength: 500,
9
+ maxStackLength: 4_000,
10
+ };
11
+ const boundedLimit = (value, fallback) => Number.isSafeInteger(value) && value > 0 ? value : fallback;
12
+ const normalizeLimits = (limits) => ({
13
+ maxDepth: boundedLimit(limits?.maxDepth ?? defaultLimits.maxDepth, defaultLimits.maxDepth),
14
+ maxKeys: boundedLimit(limits?.maxKeys ?? defaultLimits.maxKeys, defaultLimits.maxKeys),
15
+ maxStringLength: boundedLimit(limits?.maxStringLength ?? defaultLimits.maxStringLength, defaultLimits.maxStringLength),
16
+ maxStackLength: boundedLimit(limits?.maxStackLength ?? defaultLimits.maxStackLength, defaultLimits.maxStackLength),
17
+ });
18
+ const truncate = (value, limit) => value.slice(0, limit);
19
+ const readProperty = (value, key) => {
20
+ try {
21
+ return value[key];
22
+ }
23
+ catch {
24
+ return UNREADABLE_VALUE;
25
+ }
26
+ };
27
+ const normalizePrimitive = (value, limits) => {
28
+ if (value === null) {
29
+ return null;
30
+ }
31
+ switch (typeof value) {
32
+ case "string":
33
+ return truncate(value, limits.maxStringLength);
34
+ case "boolean":
35
+ return value;
36
+ case "number":
37
+ return Number.isFinite(value) ? value : "[non-finite number]";
38
+ case "undefined":
39
+ return "[undefined]";
40
+ case "bigint":
41
+ return "[bigint]";
42
+ case "symbol":
43
+ return "[symbol]";
44
+ case "function":
45
+ return "[function]";
46
+ default:
47
+ return UNREADABLE_VALUE;
48
+ }
49
+ };
50
+ const normalizeValue = (value, depth, state, limits) => {
51
+ if (value === null || typeof value !== "object") {
52
+ return normalizePrimitive(value, limits);
53
+ }
54
+ if (depth >= limits.maxDepth) {
55
+ return MAX_DEPTH_VALUE;
56
+ }
57
+ if (state.seen.has(value)) {
58
+ return CIRCULAR_VALUE;
59
+ }
60
+ state.seen.add(value);
61
+ if (Array.isArray(value)) {
62
+ const result = [];
63
+ let length;
64
+ try {
65
+ length = Math.min(value.length, limits.maxKeys - state.keysVisited);
66
+ }
67
+ catch {
68
+ return UNREADABLE_VALUE;
69
+ }
70
+ for (let index = 0; index < length; index += 1) {
71
+ if (state.keysVisited >= limits.maxKeys) {
72
+ break;
73
+ }
74
+ state.keysVisited += 1;
75
+ result.push(normalizeValue(readProperty(value, String(index)), depth + 1, state, limits));
76
+ }
77
+ return result;
78
+ }
79
+ let keys;
80
+ try {
81
+ keys = Object.keys(value);
82
+ }
83
+ catch {
84
+ return UNREADABLE_VALUE;
85
+ }
86
+ const result = {};
87
+ for (const key of keys) {
88
+ if (state.keysVisited >= limits.maxKeys - 1) {
89
+ result["[truncated]"] = MAX_KEYS_VALUE;
90
+ break;
91
+ }
92
+ state.keysVisited += 1;
93
+ result[truncate(key, limits.maxStringLength)] = normalizeValue(readProperty(value, key), depth + 1, state, limits);
94
+ }
95
+ return result;
96
+ };
97
+ export function normalizeUnknown(value, limits) {
98
+ const normalizedLimits = normalizeLimits(limits);
99
+ try {
100
+ return normalizeValue(value, 0, { seen: new WeakSet(), keysVisited: 0 }, normalizedLimits);
101
+ }
102
+ catch {
103
+ return UNREADABLE_VALUE;
104
+ }
105
+ }
106
+ const safeStringProperty = (value, key, fallback, limit) => {
107
+ const property = readProperty(value, key);
108
+ return typeof property === "string" ? truncate(property, limit) : fallback;
109
+ };
110
+ export function normalizeError(error, limits) {
111
+ const normalizedLimits = normalizeLimits(limits);
112
+ let isError;
113
+ try {
114
+ isError = error instanceof Error;
115
+ }
116
+ catch {
117
+ isError = false;
118
+ }
119
+ if (isError && error !== null && typeof error === "object") {
120
+ const name = safeStringProperty(error, "name", "Error", normalizedLimits.maxStringLength);
121
+ const message = safeStringProperty(error, "message", UNREADABLE_VALUE, normalizedLimits.maxStringLength);
122
+ const stack = safeStringProperty(error, "stack", "", normalizedLimits.maxStackLength);
123
+ const code = safeStringProperty(error, "code", "", normalizedLimits.maxStringLength);
124
+ return {
125
+ name: name || "Error",
126
+ message,
127
+ ...(stack ? { stack } : {}),
128
+ ...(code ? { code } : {}),
129
+ };
130
+ }
131
+ const normalized = normalizeUnknown(error, normalizedLimits);
132
+ const message = typeof normalized === "string" ? normalized : UNREADABLE_VALUE;
133
+ return { name: "UnknownError", message };
134
+ }