@buenojs/bueno 0.8.4 → 0.8.6

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 (234) hide show
  1. package/README.md +264 -17
  2. package/dist/cli/{index.js → bin.js} +413 -332
  3. package/dist/container/index.js +273 -0
  4. package/dist/context/index.js +219 -0
  5. package/dist/database/index.js +493 -0
  6. package/dist/frontend/index.js +7697 -0
  7. package/dist/graphql/index.js +2156 -0
  8. package/dist/health/index.js +364 -0
  9. package/dist/i18n/index.js +345 -0
  10. package/dist/index.js +9694 -5047
  11. package/dist/jobs/index.js +819 -0
  12. package/dist/lock/index.js +367 -0
  13. package/dist/logger/index.js +281 -0
  14. package/dist/metrics/index.js +289 -0
  15. package/dist/middleware/index.js +77 -0
  16. package/dist/migrations/index.js +571 -0
  17. package/dist/modules/index.js +3411 -0
  18. package/dist/notification/index.js +484 -0
  19. package/dist/observability/index.js +331 -0
  20. package/dist/openapi/index.js +795 -0
  21. package/dist/orm/index.js +1356 -0
  22. package/dist/router/index.js +886 -0
  23. package/dist/rpc/index.js +691 -0
  24. package/dist/schema/index.js +400 -0
  25. package/dist/telemetry/index.js +595 -0
  26. package/dist/template/index.js +640 -0
  27. package/dist/templates/index.js +640 -0
  28. package/dist/testing/index.js +1111 -0
  29. package/dist/types/index.js +60 -0
  30. package/llms.txt +231 -0
  31. package/package.json +125 -27
  32. package/src/cache/index.ts +2 -1
  33. package/src/cli/ARCHITECTURE.md +3 -3
  34. package/src/cli/bin.ts +2 -2
  35. package/src/cli/commands/build.ts +183 -165
  36. package/src/cli/commands/dev.ts +96 -89
  37. package/src/cli/commands/generate.ts +142 -111
  38. package/src/cli/commands/help.ts +20 -16
  39. package/src/cli/commands/index.ts +3 -6
  40. package/src/cli/commands/migration.ts +124 -105
  41. package/src/cli/commands/new.ts +294 -232
  42. package/src/cli/commands/start.ts +81 -79
  43. package/src/cli/core/args.ts +68 -50
  44. package/src/cli/core/console.ts +89 -95
  45. package/src/cli/core/index.ts +4 -4
  46. package/src/cli/core/prompt.ts +65 -62
  47. package/src/cli/core/spinner.ts +23 -20
  48. package/src/cli/index.ts +46 -38
  49. package/src/cli/templates/database/index.ts +37 -18
  50. package/src/cli/templates/database/mysql.ts +3 -3
  51. package/src/cli/templates/database/none.ts +2 -2
  52. package/src/cli/templates/database/postgresql.ts +3 -3
  53. package/src/cli/templates/database/sqlite.ts +3 -3
  54. package/src/cli/templates/deploy.ts +29 -26
  55. package/src/cli/templates/docker.ts +41 -30
  56. package/src/cli/templates/frontend/index.ts +33 -15
  57. package/src/cli/templates/frontend/none.ts +2 -2
  58. package/src/cli/templates/frontend/react.ts +18 -18
  59. package/src/cli/templates/frontend/solid.ts +15 -15
  60. package/src/cli/templates/frontend/svelte.ts +17 -17
  61. package/src/cli/templates/frontend/vue.ts +15 -15
  62. package/src/cli/templates/generators/index.ts +29 -29
  63. package/src/cli/templates/generators/types.ts +21 -21
  64. package/src/cli/templates/index.ts +6 -6
  65. package/src/cli/templates/project/api.ts +37 -36
  66. package/src/cli/templates/project/default.ts +25 -25
  67. package/src/cli/templates/project/fullstack.ts +28 -26
  68. package/src/cli/templates/project/index.ts +55 -16
  69. package/src/cli/templates/project/minimal.ts +17 -12
  70. package/src/cli/templates/project/types.ts +10 -5
  71. package/src/cli/templates/project/website.ts +15 -15
  72. package/src/cli/utils/fs.ts +55 -41
  73. package/src/cli/utils/index.ts +3 -3
  74. package/src/cli/utils/strings.ts +47 -33
  75. package/src/cli/utils/version.ts +14 -8
  76. package/src/config/env-validation.ts +100 -0
  77. package/src/config/env.ts +169 -41
  78. package/src/config/index.ts +28 -20
  79. package/src/config/loader.ts +25 -16
  80. package/src/config/merge.ts +21 -10
  81. package/src/config/types.ts +566 -25
  82. package/src/config/validation.ts +215 -7
  83. package/src/container/forward-ref.ts +22 -22
  84. package/src/container/index.ts +34 -12
  85. package/src/context/index.ts +11 -1
  86. package/src/database/index.ts +7 -190
  87. package/src/database/orm/builder.ts +457 -0
  88. package/src/database/orm/casts/index.ts +130 -0
  89. package/src/database/orm/casts/types.ts +25 -0
  90. package/src/database/orm/compiler.ts +304 -0
  91. package/src/database/orm/hooks/index.ts +114 -0
  92. package/src/database/orm/index.ts +61 -0
  93. package/src/database/orm/model-registry.ts +59 -0
  94. package/src/database/orm/model.ts +821 -0
  95. package/src/database/orm/relationships/base.ts +146 -0
  96. package/src/database/orm/relationships/belongs-to-many.ts +179 -0
  97. package/src/database/orm/relationships/belongs-to.ts +56 -0
  98. package/src/database/orm/relationships/has-many.ts +45 -0
  99. package/src/database/orm/relationships/has-one.ts +41 -0
  100. package/src/database/orm/relationships/index.ts +11 -0
  101. package/src/database/orm/scopes/index.ts +55 -0
  102. package/src/events/__tests__/event-system.test.ts +235 -0
  103. package/src/events/config.ts +238 -0
  104. package/src/events/example-usage.ts +185 -0
  105. package/src/events/index.ts +278 -0
  106. package/src/events/manager.ts +385 -0
  107. package/src/events/registry.ts +182 -0
  108. package/src/events/types.ts +124 -0
  109. package/src/frontend/api-routes.ts +65 -23
  110. package/src/frontend/bundler.ts +76 -34
  111. package/src/frontend/console-client.ts +2 -2
  112. package/src/frontend/console-stream.ts +94 -38
  113. package/src/frontend/dev-server.ts +94 -46
  114. package/src/frontend/file-router.ts +61 -19
  115. package/src/frontend/frameworks/index.ts +37 -10
  116. package/src/frontend/frameworks/react.ts +10 -8
  117. package/src/frontend/frameworks/solid.ts +11 -9
  118. package/src/frontend/frameworks/svelte.ts +15 -9
  119. package/src/frontend/frameworks/vue.ts +13 -11
  120. package/src/frontend/hmr-client.ts +12 -10
  121. package/src/frontend/hmr.ts +146 -103
  122. package/src/frontend/index.ts +14 -5
  123. package/src/frontend/islands.ts +41 -22
  124. package/src/frontend/isr.ts +59 -37
  125. package/src/frontend/layout.ts +36 -21
  126. package/src/frontend/ssr/react.ts +74 -27
  127. package/src/frontend/ssr/solid.ts +54 -20
  128. package/src/frontend/ssr/svelte.ts +48 -14
  129. package/src/frontend/ssr/vue.ts +50 -18
  130. package/src/frontend/ssr.ts +83 -39
  131. package/src/frontend/types.ts +91 -56
  132. package/src/graphql/built-in-engine.ts +598 -0
  133. package/src/graphql/context-builder.ts +110 -0
  134. package/src/graphql/decorators.ts +358 -0
  135. package/src/graphql/execution-pipeline.ts +227 -0
  136. package/src/graphql/graphql-module.ts +563 -0
  137. package/src/graphql/index.ts +101 -0
  138. package/src/graphql/metadata.ts +237 -0
  139. package/src/graphql/schema-builder.ts +319 -0
  140. package/src/graphql/subscription-handler.ts +283 -0
  141. package/src/graphql/types.ts +324 -0
  142. package/src/health/index.ts +21 -9
  143. package/src/i18n/engine.ts +305 -0
  144. package/src/i18n/index.ts +38 -0
  145. package/src/i18n/loader.ts +218 -0
  146. package/src/i18n/middleware.ts +164 -0
  147. package/src/i18n/negotiator.ts +162 -0
  148. package/src/i18n/types.ts +158 -0
  149. package/src/index.ts +182 -27
  150. package/src/jobs/drivers/memory.ts +315 -0
  151. package/src/jobs/drivers/redis.ts +459 -0
  152. package/src/jobs/index.ts +30 -0
  153. package/src/jobs/queue.ts +281 -0
  154. package/src/jobs/types.ts +295 -0
  155. package/src/jobs/worker.ts +380 -0
  156. package/src/logger/index.ts +1 -3
  157. package/src/logger/transports/index.ts +62 -22
  158. package/src/metrics/index.ts +25 -16
  159. package/src/migrations/index.ts +9 -0
  160. package/src/modules/filters.ts +13 -17
  161. package/src/modules/guards.ts +49 -26
  162. package/src/modules/index.ts +457 -299
  163. package/src/modules/interceptors.ts +58 -20
  164. package/src/modules/lazy.ts +11 -19
  165. package/src/modules/lifecycle.ts +15 -7
  166. package/src/modules/metadata.ts +15 -5
  167. package/src/modules/pipes.ts +94 -72
  168. package/src/notification/channels/base.ts +68 -0
  169. package/src/notification/channels/email.ts +105 -0
  170. package/src/notification/channels/push.ts +104 -0
  171. package/src/notification/channels/sms.ts +105 -0
  172. package/src/notification/channels/whatsapp.ts +104 -0
  173. package/src/notification/index.ts +48 -0
  174. package/src/notification/service.ts +354 -0
  175. package/src/notification/types.ts +344 -0
  176. package/src/observability/__tests__/observability.test.ts +483 -0
  177. package/src/observability/breadcrumbs.ts +114 -0
  178. package/src/observability/index.ts +136 -0
  179. package/src/observability/interceptor.ts +85 -0
  180. package/src/observability/service.ts +303 -0
  181. package/src/observability/trace.ts +37 -0
  182. package/src/observability/types.ts +196 -0
  183. package/src/openapi/__tests__/decorators.test.ts +335 -0
  184. package/src/openapi/__tests__/document-builder.test.ts +285 -0
  185. package/src/openapi/__tests__/route-scanner.test.ts +334 -0
  186. package/src/openapi/__tests__/schema-generator.test.ts +275 -0
  187. package/src/openapi/decorators.ts +328 -0
  188. package/src/openapi/document-builder.ts +274 -0
  189. package/src/openapi/index.ts +112 -0
  190. package/src/openapi/metadata.ts +112 -0
  191. package/src/openapi/route-scanner.ts +289 -0
  192. package/src/openapi/schema-generator.ts +256 -0
  193. package/src/openapi/swagger-module.ts +166 -0
  194. package/src/openapi/types.ts +398 -0
  195. package/src/orm/index.ts +10 -0
  196. package/src/rpc/index.ts +3 -1
  197. package/src/schema/index.ts +9 -0
  198. package/src/security/index.ts +15 -6
  199. package/src/ssg/index.ts +9 -8
  200. package/src/telemetry/index.ts +76 -22
  201. package/src/template/index.ts +7 -0
  202. package/src/templates/engine.ts +224 -0
  203. package/src/templates/index.ts +9 -0
  204. package/src/templates/loader.ts +331 -0
  205. package/src/templates/renderers/markdown.ts +212 -0
  206. package/src/templates/renderers/simple.ts +269 -0
  207. package/src/templates/types.ts +154 -0
  208. package/src/testing/index.ts +100 -27
  209. package/src/types/optional-deps.d.ts +347 -187
  210. package/src/validation/index.ts +92 -2
  211. package/src/validation/schemas.ts +536 -0
  212. package/tests/integration/cli.test.ts +19 -19
  213. package/tests/integration/fullstack.test.ts +4 -4
  214. package/tests/unit/cli.test.ts +1 -1
  215. package/tests/unit/database.test.ts +2 -72
  216. package/tests/unit/env-validation.test.ts +166 -0
  217. package/tests/unit/events.test.ts +910 -0
  218. package/tests/unit/graphql.test.ts +991 -0
  219. package/tests/unit/i18n.test.ts +455 -0
  220. package/tests/unit/jobs.test.ts +493 -0
  221. package/tests/unit/notification.test.ts +988 -0
  222. package/tests/unit/observability.test.ts +453 -0
  223. package/tests/unit/orm/builder.test.ts +323 -0
  224. package/tests/unit/orm/casts.test.ts +179 -0
  225. package/tests/unit/orm/compiler.test.ts +220 -0
  226. package/tests/unit/orm/eager-loading.test.ts +285 -0
  227. package/tests/unit/orm/hooks.test.ts +191 -0
  228. package/tests/unit/orm/model.test.ts +373 -0
  229. package/tests/unit/orm/relationships.test.ts +303 -0
  230. package/tests/unit/orm/scopes.test.ts +74 -0
  231. package/tests/unit/templates-simple.test.ts +53 -0
  232. package/tests/unit/templates.test.ts +454 -0
  233. package/tests/unit/validation.test.ts +18 -24
  234. package/tsconfig.json +11 -3
@@ -0,0 +1,483 @@
1
+ import { describe, test, expect, beforeEach } from "bun:test";
2
+ import { Context } from "../../context";
3
+ import {
4
+ BreadcrumbCollector,
5
+ httpBreadcrumb,
6
+ logBreadcrumb,
7
+ } from "../breadcrumbs";
8
+ import { ObservabilityService } from "../service";
9
+ import { ObservabilityInterceptor } from "../interceptor";
10
+ import { generateTraceId, generateSpanId, buildTraceparent } from "../trace";
11
+ import { extractTraceContext } from "../service";
12
+ import type { ErrorEvent, MessageEvent, ErrorReporter } from "../types";
13
+
14
+ // ============= Helpers =============
15
+
16
+ function makeRequest(
17
+ path = "/test",
18
+ method = "GET",
19
+ headers: Record<string, string> = {},
20
+ ): Request {
21
+ return new Request(`http://localhost${path}`, { method, headers });
22
+ }
23
+
24
+ function makeContext(
25
+ path = "/test",
26
+ method = "GET",
27
+ headers: Record<string, string> = {},
28
+ ): Context {
29
+ return new Context(makeRequest(path, method, headers));
30
+ }
31
+
32
+ class CollectingReporter implements ErrorReporter {
33
+ errors: ErrorEvent[] = [];
34
+ messages: MessageEvent[] = [];
35
+ flushed = false;
36
+
37
+ captureError(event: ErrorEvent): void {
38
+ this.errors.push(event);
39
+ }
40
+ captureMessage(event: MessageEvent): void {
41
+ this.messages.push(event);
42
+ }
43
+ async flush(): Promise<void> {
44
+ this.flushed = true;
45
+ }
46
+ }
47
+
48
+ // ============= BreadcrumbCollector =============
49
+
50
+ describe("BreadcrumbCollector", () => {
51
+ test("stores breadcrumbs in order", () => {
52
+ const collector = new BreadcrumbCollector(5);
53
+ collector.add({ timestamp: new Date(), type: "log", level: "info", message: "a" });
54
+ collector.add({ timestamp: new Date(), type: "log", level: "info", message: "b" });
55
+ collector.add({ timestamp: new Date(), type: "log", level: "info", message: "c" });
56
+
57
+ const all = collector.getAll();
58
+ expect(all).toHaveLength(3);
59
+ expect(all[0].message).toBe("a");
60
+ expect(all[2].message).toBe("c");
61
+ });
62
+
63
+ test("evicts oldest entries when buffer is full", () => {
64
+ const collector = new BreadcrumbCollector(3);
65
+ collector.add({ timestamp: new Date(), type: "log", level: "info", message: "1" });
66
+ collector.add({ timestamp: new Date(), type: "log", level: "info", message: "2" });
67
+ collector.add({ timestamp: new Date(), type: "log", level: "info", message: "3" });
68
+ collector.add({ timestamp: new Date(), type: "log", level: "info", message: "4" }); // evicts "1"
69
+
70
+ const all = collector.getAll();
71
+ expect(all).toHaveLength(3);
72
+ expect(all[0].message).toBe("2");
73
+ expect(all[2].message).toBe("4");
74
+ });
75
+
76
+ test("evicts multiple old entries correctly", () => {
77
+ const collector = new BreadcrumbCollector(2);
78
+ for (let i = 1; i <= 5; i++) {
79
+ collector.add({ timestamp: new Date(), type: "log", level: "info", message: String(i) });
80
+ }
81
+ const all = collector.getAll();
82
+ expect(all).toHaveLength(2);
83
+ expect(all[0].message).toBe("4");
84
+ expect(all[1].message).toBe("5");
85
+ });
86
+
87
+ test("reports correct size", () => {
88
+ const collector = new BreadcrumbCollector(10);
89
+ expect(collector.size).toBe(0);
90
+ collector.add({ timestamp: new Date(), type: "log", level: "info", message: "x" });
91
+ expect(collector.size).toBe(1);
92
+ });
93
+
94
+ test("does not exceed maxSize", () => {
95
+ const collector = new BreadcrumbCollector(3);
96
+ for (let i = 0; i < 10; i++) {
97
+ collector.add({ timestamp: new Date(), type: "log", level: "info", message: String(i) });
98
+ }
99
+ expect(collector.size).toBe(3);
100
+ expect(collector.maxSize).toBe(3);
101
+ });
102
+
103
+ test("clear() empties the buffer", () => {
104
+ const collector = new BreadcrumbCollector(5);
105
+ collector.add({ timestamp: new Date(), type: "log", level: "info", message: "a" });
106
+ collector.add({ timestamp: new Date(), type: "log", level: "info", message: "b" });
107
+ collector.clear();
108
+ expect(collector.size).toBe(0);
109
+ expect(collector.getAll()).toEqual([]);
110
+ });
111
+
112
+ test("throws when maxSize < 1", () => {
113
+ expect(() => new BreadcrumbCollector(0)).toThrow(RangeError);
114
+ });
115
+
116
+ test("getAll() returns empty array when empty", () => {
117
+ const collector = new BreadcrumbCollector(5);
118
+ expect(collector.getAll()).toEqual([]);
119
+ });
120
+
121
+ test("single-slot buffer works correctly", () => {
122
+ const collector = new BreadcrumbCollector(1);
123
+ collector.add({ timestamp: new Date(), type: "log", level: "info", message: "a" });
124
+ collector.add({ timestamp: new Date(), type: "log", level: "info", message: "b" });
125
+ const all = collector.getAll();
126
+ expect(all).toHaveLength(1);
127
+ expect(all[0].message).toBe("b");
128
+ });
129
+ });
130
+
131
+ // ============= Breadcrumb Helpers =============
132
+
133
+ describe("httpBreadcrumb", () => {
134
+ test("creates an http breadcrumb", () => {
135
+ const crumb = httpBreadcrumb("GET", "/users");
136
+ expect(crumb.type).toBe("http");
137
+ expect(crumb.message).toBe("GET /users");
138
+ expect(crumb.level).toBe("info");
139
+ expect(crumb.data?.method).toBe("GET");
140
+ expect(crumb.data?.path).toBe("/users");
141
+ });
142
+
143
+ test("includes status code in message and data", () => {
144
+ const crumb = httpBreadcrumb("POST", "/api/orders", 201, 42);
145
+ expect(crumb.message).toBe("POST /api/orders 201");
146
+ expect(crumb.data?.statusCode).toBe(201);
147
+ expect(crumb.data?.durationMs).toBe(42);
148
+ });
149
+
150
+ test("marks error level for 4xx/5xx responses", () => {
151
+ expect(httpBreadcrumb("GET", "/notfound", 404).level).toBe("error");
152
+ expect(httpBreadcrumb("GET", "/error", 500).level).toBe("error");
153
+ expect(httpBreadcrumb("GET", "/ok", 200).level).toBe("info");
154
+ });
155
+ });
156
+
157
+ describe("logBreadcrumb", () => {
158
+ test("creates a log breadcrumb", () => {
159
+ const crumb = logBreadcrumb("warning", "Slow query detected", { ms: 2000 });
160
+ expect(crumb.type).toBe("log");
161
+ expect(crumb.level).toBe("warning");
162
+ expect(crumb.message).toBe("Slow query detected");
163
+ expect(crumb.data?.ms).toBe(2000);
164
+ });
165
+ });
166
+
167
+ // ============= Trace Helpers =============
168
+
169
+ describe("trace utilities", () => {
170
+ test("generateTraceId returns 32-char hex string", () => {
171
+ const id = generateTraceId();
172
+ expect(id).toHaveLength(32);
173
+ expect(/^[0-9a-f]+$/.test(id)).toBe(true);
174
+ });
175
+
176
+ test("generateSpanId returns 16-char hex string", () => {
177
+ const id = generateSpanId();
178
+ expect(id).toHaveLength(16);
179
+ expect(/^[0-9a-f]+$/.test(id)).toBe(true);
180
+ });
181
+
182
+ test("generateTraceId produces unique values", () => {
183
+ const ids = new Set(Array.from({ length: 100 }, generateTraceId));
184
+ expect(ids.size).toBe(100);
185
+ });
186
+
187
+ test("buildTraceparent formats correctly", () => {
188
+ const tp = buildTraceparent("aaa", "bbb");
189
+ expect(tp).toBe("00-aaa-bbb-01");
190
+ });
191
+
192
+ test("extractTraceContext parses valid traceparent header", () => {
193
+ const traceId = generateTraceId();
194
+ const spanId = generateSpanId();
195
+ const ctx = makeContext("/test", "GET", {
196
+ traceparent: buildTraceparent(traceId, spanId),
197
+ });
198
+ const extracted = extractTraceContext(ctx);
199
+ expect(extracted.traceId).toBe(traceId);
200
+ expect(extracted.spanId).toBe(spanId);
201
+ });
202
+
203
+ test("extractTraceContext returns empty for missing header", () => {
204
+ const ctx = makeContext("/test");
205
+ expect(extractTraceContext(ctx)).toEqual({});
206
+ });
207
+
208
+ test("extractTraceContext returns empty for malformed header", () => {
209
+ const ctx = makeContext("/test", "GET", { traceparent: "bad-format" });
210
+ expect(extractTraceContext(ctx)).toEqual({});
211
+ });
212
+ });
213
+
214
+ // ============= ObservabilityService =============
215
+
216
+ describe("ObservabilityService", () => {
217
+ let reporter: CollectingReporter;
218
+ let service: ObservabilityService;
219
+
220
+ beforeEach(() => {
221
+ reporter = new CollectingReporter();
222
+ service = new ObservabilityService({ reporter });
223
+ });
224
+
225
+ test("captureError dispatches event to reporter", async () => {
226
+ const error = new Error("test error");
227
+ service.captureError(error);
228
+ await Promise.resolve(); // flush microtasks
229
+ expect(reporter.errors).toHaveLength(1);
230
+ expect(reporter.errors[0].error).toBe(error);
231
+ expect(reporter.errors[0].level).toBe("error");
232
+ });
233
+
234
+ test("captureError uses specified level", async () => {
235
+ service.captureError(new Error("fatal"), "fatal");
236
+ await Promise.resolve();
237
+ expect(reporter.errors[0].level).toBe("fatal");
238
+ });
239
+
240
+ test("captureError attaches breadcrumbs", async () => {
241
+ service.addBreadcrumb({ type: "custom", level: "info", message: "step 1" });
242
+ service.addBreadcrumb({ type: "custom", level: "info", message: "step 2" });
243
+ service.captureError(new Error("oops"));
244
+ await Promise.resolve();
245
+ const crumbs = reporter.errors[0].breadcrumbs;
246
+ expect(crumbs.some((c) => c.message === "step 1")).toBe(true);
247
+ expect(crumbs.some((c) => c.message === "step 2")).toBe(true);
248
+ });
249
+
250
+ test("captureFromContext attaches request metadata", async () => {
251
+ const ctx = makeContext("/api/users", "POST");
252
+ service.captureFromContext(ctx, new Error("req error"));
253
+ await Promise.resolve();
254
+ const event = reporter.errors[0];
255
+ expect(event.request?.method).toBe("POST");
256
+ expect(event.request?.path).toBe("/api/users");
257
+ });
258
+
259
+ test("captureFromContext reads traceId from context variables", async () => {
260
+ const ctx = makeContext("/test");
261
+ ctx.set("traceId", "abc123trace");
262
+ ctx.set("spanId", "span456");
263
+ service.captureFromContext(ctx, new Error("traced error"));
264
+ await Promise.resolve();
265
+ expect(reporter.errors[0].traceId).toBe("abc123trace");
266
+ expect(reporter.errors[0].spanId).toBe("span456");
267
+ });
268
+
269
+ test("captureFromContext reads user from context", async () => {
270
+ const ctx = makeContext("/test");
271
+ ctx.set("user", { id: 42, email: "alice@example.com" });
272
+ service.captureFromContext(ctx, new Error("user error"));
273
+ await Promise.resolve();
274
+ expect(reporter.errors[0].user?.id).toBe(42);
275
+ expect(reporter.errors[0].user?.email).toBe("alice@example.com");
276
+ });
277
+
278
+ test("captureFromContext strips sensitive headers", async () => {
279
+ const ctx = makeContext("/test", "GET", {
280
+ authorization: "Bearer secret",
281
+ "x-api-key": "key123",
282
+ "content-type": "application/json",
283
+ });
284
+ service.captureFromContext(ctx, new Error("header test"));
285
+ await Promise.resolve();
286
+ const headers = reporter.errors[0].request?.headers ?? {};
287
+ expect("authorization" in headers).toBe(false);
288
+ expect("x-api-key" in headers).toBe(false);
289
+ expect(headers["content-type"]).toBe("application/json");
290
+ });
291
+
292
+ test("ignoreErrors suppresses matching error classes", async () => {
293
+ class CustomError extends Error {}
294
+ const svc = new ObservabilityService({
295
+ reporter,
296
+ ignoreErrors: [CustomError],
297
+ });
298
+ svc.captureError(new CustomError("ignored"));
299
+ await Promise.resolve();
300
+ expect(reporter.errors).toHaveLength(0);
301
+ });
302
+
303
+ test("ignoreStatusCodes suppresses matching status codes", async () => {
304
+ const svc = new ObservabilityService({
305
+ reporter,
306
+ ignoreStatusCodes: [404],
307
+ });
308
+ const err = Object.assign(new Error("not found"), { statusCode: 404 });
309
+ svc.captureError(err);
310
+ await Promise.resolve();
311
+ expect(reporter.errors).toHaveLength(0);
312
+ });
313
+
314
+ test("ignoreStatusCodes does not suppress other status codes", async () => {
315
+ const svc = new ObservabilityService({
316
+ reporter,
317
+ ignoreStatusCodes: [404],
318
+ });
319
+ const err = Object.assign(new Error("server error"), { statusCode: 500 });
320
+ svc.captureError(err);
321
+ await Promise.resolve();
322
+ expect(reporter.errors).toHaveLength(1);
323
+ });
324
+
325
+ test("tags are attached to every event", async () => {
326
+ const svc = new ObservabilityService({
327
+ reporter,
328
+ tags: { environment: "test", version: "1.0.0" },
329
+ });
330
+ svc.captureError(new Error("tagged"));
331
+ await Promise.resolve();
332
+ expect(reporter.errors[0].tags?.environment).toBe("test");
333
+ expect(reporter.errors[0].tags?.version).toBe("1.0.0");
334
+ });
335
+
336
+ test("captureMessage sends message event", async () => {
337
+ service.captureMessage("Hello world", "info", { key: "value" });
338
+ await Promise.resolve();
339
+ expect(reporter.messages).toHaveLength(1);
340
+ expect(reporter.messages[0].message).toBe("Hello world");
341
+ expect(reporter.messages[0].level).toBe("info");
342
+ expect(reporter.messages[0].extra?.key).toBe("value");
343
+ });
344
+
345
+ test("addBreadcrumb defaults timestamp to now", () => {
346
+ const before = new Date();
347
+ service.addBreadcrumb({ type: "custom", level: "debug", message: "x" });
348
+ const after = new Date();
349
+ const crumb = service.getBreadcrumbCollector().getAll()[0];
350
+ expect(crumb.timestamp >= before).toBe(true);
351
+ expect(crumb.timestamp <= after).toBe(true);
352
+ });
353
+
354
+ test("error events have unique IDs", async () => {
355
+ service.captureError(new Error("e1"));
356
+ service.captureError(new Error("e2"));
357
+ await Promise.resolve();
358
+ await Promise.resolve();
359
+ const ids = reporter.errors.map((e) => e.id);
360
+ expect(new Set(ids).size).toBe(ids.length);
361
+ });
362
+
363
+ test("breadcrumb size is configurable", () => {
364
+ const svc = new ObservabilityService({ reporter, breadcrumbsSize: 3 });
365
+ expect(svc.getBreadcrumbCollector().maxSize).toBe(3);
366
+ });
367
+ });
368
+
369
+ // ============= ObservabilityInterceptor =============
370
+
371
+ describe("ObservabilityInterceptor", () => {
372
+ let reporter: CollectingReporter;
373
+ let service: ObservabilityService;
374
+ let interceptor: ObservabilityInterceptor;
375
+
376
+ beforeEach(() => {
377
+ reporter = new CollectingReporter();
378
+ service = new ObservabilityService({ reporter });
379
+ interceptor = new ObservabilityInterceptor(service);
380
+ });
381
+
382
+ test("injects traceId and spanId into context", async () => {
383
+ const ctx = makeContext("/test");
384
+ let capturedCtx: Context | null = null;
385
+
386
+ await interceptor.intercept(ctx, {
387
+ handle: async () => {
388
+ capturedCtx = ctx;
389
+ return new Response("ok");
390
+ },
391
+ });
392
+
393
+ expect(capturedCtx!.get("traceId")).toBeTruthy();
394
+ expect(capturedCtx!.get("spanId")).toBeTruthy();
395
+ expect(String(capturedCtx!.get("traceId"))).toHaveLength(32);
396
+ });
397
+
398
+ test("extracts traceId from traceparent header if present", async () => {
399
+ const traceId = generateTraceId();
400
+ const spanId = generateSpanId();
401
+ const ctx = makeContext("/test", "GET", {
402
+ traceparent: buildTraceparent(traceId, spanId),
403
+ });
404
+
405
+ await interceptor.intercept(ctx, {
406
+ handle: async () => new Response("ok"),
407
+ });
408
+
409
+ expect(ctx.get("traceId")).toBe(traceId);
410
+ expect(ctx.get("spanId")).toBe(spanId);
411
+ });
412
+
413
+ test("adds navigation breadcrumb on request entry", async () => {
414
+ const ctx = makeContext("/api/items", "GET");
415
+ await interceptor.intercept(ctx, {
416
+ handle: async () => new Response("ok"),
417
+ });
418
+ const crumbs = service.getBreadcrumbCollector().getAll();
419
+ const navCrumb = crumbs.find((c) => c.type === "navigation");
420
+ expect(navCrumb).toBeDefined();
421
+ expect(navCrumb?.message).toBe("GET /api/items");
422
+ });
423
+
424
+ test("adds http breadcrumb on successful request exit", async () => {
425
+ const ctx = makeContext("/api/items", "GET");
426
+ await interceptor.intercept(ctx, {
427
+ handle: async () => new Response("ok", { status: 200 }),
428
+ });
429
+ const crumbs = service.getBreadcrumbCollector().getAll();
430
+ const httpCrumb = crumbs.find(
431
+ (c) => c.type === "http" && c.data?.statusCode === 200,
432
+ );
433
+ expect(httpCrumb).toBeDefined();
434
+ });
435
+
436
+ test("captures error and rethrows when handler throws", async () => {
437
+ const ctx = makeContext("/api/fail", "DELETE");
438
+ const boom = new Error("Handler exploded");
439
+
440
+ let threw = false;
441
+ try {
442
+ await interceptor.intercept(ctx, {
443
+ handle: async () => { throw boom; },
444
+ });
445
+ } catch (e) {
446
+ threw = true;
447
+ expect(e).toBe(boom);
448
+ }
449
+
450
+ expect(threw).toBe(true);
451
+ // Give async reporter a tick
452
+ await Promise.resolve();
453
+ expect(reporter.errors).toHaveLength(1);
454
+ expect(reporter.errors[0].error).toBe(boom);
455
+ });
456
+
457
+ test("error event contains request context after intercept", async () => {
458
+ const ctx = makeContext("/api/protected", "POST");
459
+ ctx.set("user", { id: 7, email: "bob@example.com" });
460
+
461
+ try {
462
+ await interceptor.intercept(ctx, {
463
+ handle: async () => { throw new Error("auth failed"); },
464
+ });
465
+ } catch { /* expected */ }
466
+
467
+ await Promise.resolve();
468
+ const event = reporter.errors[0];
469
+ expect(event.request?.path).toBe("/api/protected");
470
+ expect(event.user?.id).toBe(7);
471
+ });
472
+
473
+ test("returns handler result on success", async () => {
474
+ const ctx = makeContext("/test");
475
+ const response = new Response(JSON.stringify({ ok: true }), { status: 200 });
476
+
477
+ const result = await interceptor.intercept(ctx, {
478
+ handle: async () => response,
479
+ });
480
+
481
+ expect(result).toBe(response);
482
+ });
483
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Breadcrumb Collector
3
+ *
4
+ * Ring-buffer implementation for tracking recent events leading up to an error.
5
+ * When the buffer is full, the oldest entry is evicted to make room for new ones.
6
+ */
7
+
8
+ import type { BreadcrumbEntry } from "./types";
9
+
10
+ /**
11
+ * Fixed-size ring buffer for breadcrumb entries.
12
+ * Thread-safe for single-threaded Bun environments.
13
+ */
14
+ export class BreadcrumbCollector {
15
+ private readonly _buffer: BreadcrumbEntry[];
16
+ private readonly _maxSize: number;
17
+ private _head = 0;
18
+ private _size = 0;
19
+
20
+ constructor(maxSize = 20) {
21
+ if (maxSize < 1) throw new RangeError("maxSize must be at least 1");
22
+ this._maxSize = maxSize;
23
+ this._buffer = new Array(maxSize);
24
+ }
25
+
26
+ /**
27
+ * Add a breadcrumb to the ring buffer.
28
+ * If the buffer is full, the oldest entry is evicted.
29
+ */
30
+ add(entry: BreadcrumbEntry): void {
31
+ const index = (this._head + this._size) % this._maxSize;
32
+ this._buffer[index] = entry;
33
+
34
+ if (this._size < this._maxSize) {
35
+ this._size++;
36
+ } else {
37
+ // Buffer full: advance head to evict oldest
38
+ this._head = (this._head + 1) % this._maxSize;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Return all breadcrumbs in chronological order (oldest first).
44
+ */
45
+ getAll(): BreadcrumbEntry[] {
46
+ const result: BreadcrumbEntry[] = [];
47
+ for (let i = 0; i < this._size; i++) {
48
+ result.push(this._buffer[(this._head + i) % this._maxSize]);
49
+ }
50
+ return result;
51
+ }
52
+
53
+ /**
54
+ * Clear all breadcrumbs.
55
+ */
56
+ clear(): void {
57
+ this._head = 0;
58
+ this._size = 0;
59
+ }
60
+
61
+ /**
62
+ * Current number of stored breadcrumbs.
63
+ */
64
+ get size(): number {
65
+ return this._size;
66
+ }
67
+
68
+ /**
69
+ * Maximum capacity of this collector.
70
+ */
71
+ get maxSize(): number {
72
+ return this._maxSize;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Convenience helper to build a breadcrumb entry from HTTP request info.
78
+ */
79
+ export function httpBreadcrumb(
80
+ method: string,
81
+ path: string,
82
+ statusCode?: number,
83
+ durationMs?: number,
84
+ ): BreadcrumbEntry {
85
+ const level = statusCode !== undefined && statusCode >= 400 ? "error" : "info";
86
+ const data: Record<string, unknown> = { method, path };
87
+ if (statusCode !== undefined) data.statusCode = statusCode;
88
+ if (durationMs !== undefined) data.durationMs = durationMs;
89
+
90
+ return {
91
+ timestamp: new Date(),
92
+ type: "http",
93
+ level,
94
+ message: `${method} ${path}${statusCode !== undefined ? ` ${statusCode}` : ""}`,
95
+ data,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Convenience helper to build a log breadcrumb.
101
+ */
102
+ export function logBreadcrumb(
103
+ level: BreadcrumbEntry["level"],
104
+ message: string,
105
+ data?: Record<string, unknown>,
106
+ ): BreadcrumbEntry {
107
+ return {
108
+ timestamp: new Date(),
109
+ type: "log",
110
+ level,
111
+ message,
112
+ data,
113
+ };
114
+ }