@homeflare/alchemy 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +22 -0
  2. package/dist/forgejo/index.js +4 -2
  3. package/dist/forgejo/index.js.map +3 -3
  4. package/dist/forgejo/team-member.d.ts +25 -0
  5. package/dist/forgejo/team-member.d.ts.map +1 -1
  6. package/dist/proxmox/generated/constraints/index.d.ts +1 -1
  7. package/dist/proxmox/generated/constraints/index.d.ts.map +1 -1
  8. package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
  9. package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
  10. package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
  11. package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
  12. package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
  13. package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
  14. package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
  15. package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
  16. package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
  17. package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
  18. package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
  19. package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
  20. package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
  21. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
  22. package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts +23 -0
  23. package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts.map +1 -0
  24. package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
  25. package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
  26. package/dist/proxmox/index.d.ts +14 -3
  27. package/dist/proxmox/index.d.ts.map +1 -1
  28. package/dist/proxmox/index.js +565 -133
  29. package/dist/proxmox/index.js.map +33 -23
  30. package/dist/proxmox/qemu-form.d.ts +27 -18
  31. package/dist/proxmox/qemu-form.d.ts.map +1 -1
  32. package/dist/proxmox/qemu-judge.d.ts +30 -0
  33. package/dist/proxmox/qemu-judge.d.ts.map +1 -0
  34. package/dist/proxmox/qemu-lifecycle.d.ts +16 -2
  35. package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -1
  36. package/dist/proxmox/qemu-net.d.ts +9 -0
  37. package/dist/proxmox/qemu-net.d.ts.map +1 -0
  38. package/dist/proxmox/qemu-props.d.ts +112 -0
  39. package/dist/proxmox/qemu-props.d.ts.map +1 -0
  40. package/dist/proxmox/qemu-volume.d.ts +17 -0
  41. package/dist/proxmox/qemu-volume.d.ts.map +1 -0
  42. package/dist/proxmox/qemu.d.ts +14 -27
  43. package/dist/proxmox/qemu.d.ts.map +1 -1
  44. package/dist/proxmox/storage-download-errors.d.ts +5 -0
  45. package/dist/proxmox/storage-download-errors.d.ts.map +1 -0
  46. package/dist/proxmox/storage-download-identity.d.ts +27 -0
  47. package/dist/proxmox/storage-download-identity.d.ts.map +1 -0
  48. package/dist/proxmox/storage-download-lifecycle.d.ts +97 -0
  49. package/dist/proxmox/storage-download-lifecycle.d.ts.map +1 -0
  50. package/dist/proxmox/storage-download-props.d.ts +36 -0
  51. package/dist/proxmox/storage-download-props.d.ts.map +1 -0
  52. package/dist/proxmox/storage-download-task.d.ts +17 -0
  53. package/dist/proxmox/storage-download-task.d.ts.map +1 -0
  54. package/dist/proxmox/storage-download.d.ts +44 -0
  55. package/dist/proxmox/storage-download.d.ts.map +1 -0
  56. package/dist/telemetry/fake-collector.d.ts +36 -0
  57. package/dist/telemetry/fake-collector.d.ts.map +1 -0
  58. package/dist/telemetry/index.d.ts +11 -0
  59. package/dist/telemetry/index.d.ts.map +1 -0
  60. package/dist/telemetry/index.js +223 -0
  61. package/dist/telemetry/index.js.map +12 -0
  62. package/dist/telemetry/layer.d.ts +86 -0
  63. package/dist/telemetry/layer.d.ts.map +1 -0
  64. package/dist/telemetry/redact-serialization.d.ts +21 -0
  65. package/dist/telemetry/redact-serialization.d.ts.map +1 -0
  66. package/dist/telemetry/redact.d.ts +46 -0
  67. package/dist/telemetry/redact.d.ts.map +1 -0
  68. package/dist/unifi/drift.d.ts +27 -0
  69. package/dist/unifi/drift.d.ts.map +1 -0
  70. package/dist/unifi/fake-unifi.d.ts +5 -1
  71. package/dist/unifi/fake-unifi.d.ts.map +1 -1
  72. package/dist/unifi/firewall-zone-form.d.ts +6 -0
  73. package/dist/unifi/firewall-zone-form.d.ts.map +1 -1
  74. package/dist/unifi/firewall-zone.d.ts +1 -1
  75. package/dist/unifi/firewall-zone.d.ts.map +1 -1
  76. package/dist/unifi/index.d.ts +4 -3
  77. package/dist/unifi/index.d.ts.map +1 -1
  78. package/dist/unifi/index.js +138 -58
  79. package/dist/unifi/index.js.map +10 -8
  80. package/dist/unifi/network-drift.d.ts +26 -0
  81. package/dist/unifi/network-drift.d.ts.map +1 -0
  82. package/dist/unifi/network-form.d.ts +40 -7
  83. package/dist/unifi/network-form.d.ts.map +1 -1
  84. package/dist/unifi/network.d.ts +1 -0
  85. package/dist/unifi/network.d.ts.map +1 -1
  86. package/dist/unifi/paginate.d.ts +36 -0
  87. package/dist/unifi/paginate.d.ts.map +1 -0
  88. package/dist/unifi/resource.d.ts +36 -1
  89. package/dist/unifi/resource.d.ts.map +1 -1
  90. package/docs/telemetry-spike.md +94 -0
  91. package/docs/telemetry.md +109 -0
  92. package/docs/unifi.md +56 -0
  93. package/package.json +6 -2
  94. package/src/forgejo/team-member.test.ts +14 -0
  95. package/src/forgejo/team-member.ts +28 -1
  96. package/src/proxmox/constraints-required.test.ts +1 -0
  97. package/src/proxmox/generated/constraints/index.ts +3 -1
  98. package/src/proxmox/generated/constraints/pve-access.ts +1 -1
  99. package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
  100. package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
  101. package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
  102. package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
  103. package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
  104. package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
  105. package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
  106. package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
  107. package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
  108. package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
  109. package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
  110. package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
  111. package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
  112. package/src/proxmox/generated/constraints/pve-nodes-storage.ts +30 -0
  113. package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
  114. package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
  115. package/src/proxmox/index.test.ts +11 -6
  116. package/src/proxmox/index.ts +19 -3
  117. package/src/proxmox/qemu-form.ts +49 -13
  118. package/src/proxmox/qemu-identity.test.ts +30 -0
  119. package/src/proxmox/qemu-judge.test.ts +65 -0
  120. package/src/proxmox/qemu-judge.ts +84 -0
  121. package/src/proxmox/qemu-lifecycle.test.ts +78 -19
  122. package/src/proxmox/qemu-lifecycle.ts +131 -32
  123. package/src/proxmox/qemu-net.test.ts +48 -0
  124. package/src/proxmox/qemu-net.ts +78 -0
  125. package/src/proxmox/qemu-ownership.test.ts +44 -0
  126. package/src/proxmox/qemu-props.test.ts +87 -0
  127. package/src/proxmox/qemu-props.ts +157 -0
  128. package/src/proxmox/qemu-volume.test.ts +80 -0
  129. package/src/proxmox/qemu-volume.ts +121 -0
  130. package/src/proxmox/qemu.ts +15 -29
  131. package/src/proxmox/storage-download-errors.ts +7 -0
  132. package/src/proxmox/storage-download-identity.test.ts +54 -0
  133. package/src/proxmox/storage-download-identity.ts +50 -0
  134. package/src/proxmox/storage-download-lifecycle.ts +226 -0
  135. package/src/proxmox/storage-download-ownership.test.ts +50 -0
  136. package/src/proxmox/storage-download-props.ts +38 -0
  137. package/src/proxmox/storage-download-task.ts +72 -0
  138. package/src/proxmox/storage-download.test.ts +216 -0
  139. package/src/proxmox/storage-download.ts +58 -0
  140. package/src/telemetry/fake-collector.ts +59 -0
  141. package/src/telemetry/http-client-sealing.test.ts +82 -0
  142. package/src/telemetry/index.ts +10 -0
  143. package/src/telemetry/layer.test.ts +204 -0
  144. package/src/telemetry/layer.ts +152 -0
  145. package/src/telemetry/redact-serialization.ts +178 -0
  146. package/src/telemetry/redact.test.ts +124 -0
  147. package/src/telemetry/redact.ts +147 -0
  148. package/src/unifi/drift.test.ts +78 -0
  149. package/src/unifi/drift.ts +56 -0
  150. package/src/unifi/errors-and-secrets.test.ts +127 -0
  151. package/src/unifi/fake-unifi.ts +5 -1
  152. package/src/unifi/firewall-zone-form.ts +31 -2
  153. package/src/unifi/firewall-zone.test.ts +21 -1
  154. package/src/unifi/firewall-zone.ts +1 -1
  155. package/src/unifi/get-only-guard.test.ts +165 -0
  156. package/src/unifi/index.ts +15 -1
  157. package/src/unifi/network-drift.test.ts +207 -0
  158. package/src/unifi/network-drift.ts +80 -0
  159. package/src/unifi/network-form.ts +11 -39
  160. package/src/unifi/network.test.ts +7 -82
  161. package/src/unifi/network.ts +6 -6
  162. package/src/unifi/paginate.test.ts +228 -0
  163. package/src/unifi/paginate.ts +129 -0
  164. package/src/unifi/resource.ts +73 -3
  165. package/src/unifi/write-op-reference.test.ts +236 -0
@@ -0,0 +1,204 @@
1
+ /**
2
+ * `telemetryLayer` end to end, over the REAL engine (`verify/fake-engine.ts`'s `engineOver` — real
3
+ * `Alchemy.Stack` + `Plan.make` + `apply`, an in-memory state store, no network beyond the fake
4
+ * OTLP collector below) — not a hand-built stand-in. docs/telemetry-spike.md is the exploratory run this distills
5
+ * into permanent coverage; see it for what a stack's `providers` Layer can and cannot capture.
6
+ *
7
+ * ★ A REAL `FetchHttpClient` IS MERGED IN AS `ambient`, standing in for the platform layer a real
8
+ * `alchemy.run.ts`'s `Stack.ts` provides underneath the merged `providers` — exactly the shape
9
+ * admin-transport-scope.test.ts documents for caddy. Without it the fake provider's own
10
+ * `HttpClient.HttpClient` requirement has nothing to resolve to, proving on its own (a thrown
11
+ * "Service not found") that `telemetryLayer`'s SEALED internal client never leaks out —
12
+ * http-client-sealing.test.ts makes that assertion explicit instead of relying on a crash.
13
+ */
14
+ import { afterEach, describe, expect, test } from 'bun:test';
15
+ import { Resource } from 'alchemy';
16
+ import * as Provider from 'alchemy/Provider';
17
+ import * as Effect from 'effect/Effect';
18
+ import * as Layer from 'effect/Layer';
19
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
20
+ import * as HttpClient from 'effect/unstable/http/HttpClient';
21
+ import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
22
+ import { engineOver } from '../verify/fake-engine.ts';
23
+ import { type FakeCollector, fakeCollector } from './fake-collector.ts';
24
+ import { telemetryLayer } from './layer.ts';
25
+
26
+ interface Thing extends Resource<'Test.TelemetryThing', { name: string }, { name: string }> {}
27
+ const Thing = Resource<Thing>('Test.TelemetryThing');
28
+
29
+ /** Calls `target` (`GET <target>/probe?token=…`, an `authorization` header set) inside its own
30
+ * span — a stand-in for a real provider's own `Effect.withSpan` around an SDK call. */
31
+ const probingProvider = (target: string) =>
32
+ Provider.succeed(Thing, {
33
+ read: () => Effect.succeed(undefined),
34
+ reconcile: () =>
35
+ Effect.gen(function* () {
36
+ const client = yield* HttpClient.HttpClient;
37
+ const request = HttpClientRequest.get(`${target}/probe?token=SECRET123`).pipe(
38
+ HttpClientRequest.setHeader('authorization', 'Bearer sekrit'),
39
+ );
40
+ const response = yield* client.execute(request);
41
+ yield* response.text;
42
+ return { name: 'x' };
43
+ }).pipe(Effect.withSpan('probe.custom-span')),
44
+ delete: () => Effect.void,
45
+ });
46
+
47
+ let collectors: FakeCollector[] = [];
48
+ const collector = (): FakeCollector => {
49
+ const c = fakeCollector();
50
+ collectors.push(c);
51
+ return c;
52
+ };
53
+ /** A plain HTTP server for the redteam repros below — a real "target" a provider calls, not an OTLP
54
+ * collector, so it doesn't belong in `collectors`/`FakeCollector`. */
55
+ let servers: Array<{ stop: () => void }> = [];
56
+ afterEach(() => {
57
+ for (const c of collectors) c.stop();
58
+ collectors = [];
59
+ for (const s of servers) s.stop();
60
+ servers = [];
61
+ });
62
+
63
+ /** A target that answers every request 404 — a provider call that fails. */
64
+ const notFound = (): string => {
65
+ const server = Bun.serve({
66
+ fetch: () => new Response('nope', { status: 404 }),
67
+ hostname: '127.0.0.1',
68
+ port: 0,
69
+ });
70
+ servers.push({ stop: () => void server.stop(true) });
71
+ return `http://127.0.0.1:${String(server.port)}`;
72
+ };
73
+
74
+ describe('telemetryLayer', () => {
75
+ test('sends nothing to any signal when every endpoint is left unconfigured', async () => {
76
+ const otlp = collector();
77
+ const target = collector();
78
+ const providers = Layer.mergeAll(
79
+ probingProvider(target.url),
80
+ telemetryLayer({ endpoints: {}, serviceName: 'test' }),
81
+ ).pipe(Layer.provideMerge(FetchHttpClient.layer));
82
+ await engineOver(providers).deploy(Thing('t', { name: 'x' }));
83
+ expect(otlp.requests.length).toBe(0);
84
+ });
85
+
86
+ test("provider spans, HttpClient client spans and alchemy's own engine spans all arrive", async () => {
87
+ const otlp = collector();
88
+ const target = collector();
89
+ const providers = Layer.mergeAll(
90
+ probingProvider(target.url),
91
+ telemetryLayer({ endpoints: { traces: `${otlp.url}/v1/traces` }, serviceName: 'test' }),
92
+ ).pipe(Layer.provideMerge(FetchHttpClient.layer));
93
+ await engineOver(providers).deploy(Thing('t', { name: 'x' }));
94
+ // The provider's own span, the HttpClient client span it made, and alchemy's OWN engine
95
+ // instrumentation (Apply.ts `instrumentLifecycle`/`apply`/`apply.resource`, Plan.ts
96
+ // `plan.make`) — measured together in docs/telemetry-spike.md's run against beta.79.
97
+ for (const name of [
98
+ 'probe.custom-span',
99
+ 'http.client GET',
100
+ 'provider.create',
101
+ 'apply',
102
+ 'apply.resource',
103
+ 'plan.make',
104
+ ]) {
105
+ expect(otlp.sawText(name)).toBe(true);
106
+ }
107
+ });
108
+
109
+ test('redteam PR 302 item 1: OTLP bodies are protobuf, not JSON', async () => {
110
+ const otlp = collector();
111
+ const target = collector();
112
+ const providers = Layer.mergeAll(
113
+ probingProvider(target.url),
114
+ telemetryLayer({ endpoints: { traces: `${otlp.url}/v1/traces` }, serviceName: 'test' }),
115
+ ).pipe(Layer.provideMerge(FetchHttpClient.layer));
116
+ await engineOver(providers).deploy(Thing('t', { name: 'x' }));
117
+ const request = otlp.at('/v1/traces')[0];
118
+ // VictoriaLogs/Metrics answer OTLP JSON with 400 "json encoding isn't supported" — only
119
+ // protobuf's `content-type` ingests on all three (docs/telemetry-spike.md's follow-up measurement).
120
+ expect(request?.headers.get('content-type')).toBe('application/x-protobuf');
121
+ });
122
+
123
+ test('redaction holds for a real HttpClient call: no query string, no headers, denylisted host blanked', async () => {
124
+ const otlp = collector();
125
+ const target = collector();
126
+ const targetHost = new URL(target.url).hostname;
127
+ const providers = Layer.mergeAll(
128
+ probingProvider(target.url),
129
+ telemetryLayer({
130
+ endpoints: { traces: `${otlp.url}/v1/traces` },
131
+ redaction: { denylist: [targetHost] },
132
+ serviceName: 'test',
133
+ }),
134
+ ).pipe(Layer.provideMerge(FetchHttpClient.layer));
135
+ await engineOver(providers).deploy(Thing('t', { name: 'x' }));
136
+ expect(otlp.sawText('SECRET123')).toBe(false);
137
+ expect(otlp.sawText('sekrit')).toBe(false);
138
+ expect(otlp.sawText('http.request.header')).toBe(false);
139
+ expect(otlp.sawText(targetHost)).toBe(false);
140
+ expect(otlp.sawText('<redacted>')).toBe(true);
141
+ });
142
+
143
+ test('redteam PR 302 items 2+3: a FAILED provider call no longer leaks the query secret or denylisted segments via exception/status', async () => {
144
+ const otlp = collector();
145
+ const targetUrl = notFound();
146
+ const host = new URL(targetUrl).hostname;
147
+ const secretUrl = `${targetUrl}/secret-site-abc123/probe?token=SECRET123`;
148
+ const failingProvider = Provider.succeed(Thing, {
149
+ read: () => Effect.succeed(undefined),
150
+ reconcile: () =>
151
+ Effect.gen(function* () {
152
+ const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
153
+ const response = yield* client.execute(HttpClientRequest.get(secretUrl));
154
+ yield* response.text;
155
+ return { name: 'x' };
156
+ }).pipe(Effect.withSpan('rt.provider-call')),
157
+ delete: () => Effect.void,
158
+ });
159
+ const providers = Layer.mergeAll(
160
+ failingProvider,
161
+ telemetryLayer({
162
+ endpoints: { traces: `${otlp.url}/v1/traces` },
163
+ redaction: { denylist: [host, 'secret-site-abc123'] },
164
+ serviceName: 'rt',
165
+ }),
166
+ ).pipe(Layer.provideMerge(FetchHttpClient.layer));
167
+ await engineOver(providers)
168
+ .deploy(Thing('t', { name: 'x' }))
169
+ .catch(() => undefined);
170
+ // Redteam measured this leaking via `exception.message`/`status.message`, computed from the exit's
171
+ // Cause at export time — after `wrapTracer`'s own hooks already ran. `redactedSerialization`
172
+ // (redact-serialization.ts) is the fix: it scrubs the wire-format `TraceData` one level deeper.
173
+ expect(otlp.sawText('SECRET123')).toBe(false);
174
+ expect(otlp.sawText('secret-site-abc123')).toBe(false);
175
+ expect(otlp.sawText(`${host}:`)).toBe(false);
176
+ });
177
+
178
+ test('redteam PR 302 items 2+3: a log line inside a span no longer leaks via the span event name', async () => {
179
+ const otlp = collector();
180
+ const secretUrl = 'http://private.example.test/secret-site-abc123/x?token=SECRET123';
181
+ const loggingProvider = Provider.succeed(Thing, {
182
+ read: () => Effect.succeed(undefined),
183
+ reconcile: () =>
184
+ Effect.logWarning(`calling ${secretUrl}`).pipe(
185
+ Effect.as({ name: 'x' }),
186
+ Effect.withSpan('rt.logging'),
187
+ ),
188
+ delete: () => Effect.void,
189
+ });
190
+ const providers = Layer.mergeAll(
191
+ loggingProvider,
192
+ telemetryLayer({
193
+ endpoints: { traces: `${otlp.url}/v1/traces` },
194
+ redaction: { denylist: ['private.example.test', 'secret-site-abc123'] },
195
+ serviceName: 'rt',
196
+ }),
197
+ );
198
+ await engineOver(providers).deploy(Thing('t', { name: 'x' }));
199
+ // effect's default `tracerLogger` turns this into a span EVENT whose NAME is the log message —
200
+ // `wrapTracer.event()` redacts the attributes but passed the name straight through before this fix.
201
+ expect(otlp.sawText('SECRET123')).toBe(false);
202
+ expect(otlp.sawText('secret-site-abc123')).toBe(false);
203
+ });
204
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * A Layer factory for OTLP tracing/logging/metrics, built for a stack's own `providers` Layer
3
+ * rather than the CLI-wide install alchemy's own `Telemetry/Layer.ts` does (`src/Telemetry/
4
+ * Layer.ts:13-15`, hard-coded to `otel.alchemy.run` with no override). Nothing here calls that
5
+ * endpoint or knows it exists.
6
+ *
7
+ * ⛔ NO DEFAULTS THAT POINT ANYWHERE. `endpoints` is `{}` by default and `telemetryLayer` resolves
8
+ * to `Layer.empty` when every entry is `undefined` — see layer.test.ts's own "nothing sent
9
+ * when unconfigured". A consumer wires actual hosts from ITS OWN site config; see docs/telemetry.md.
10
+ * ★ SEALED TRANSPORT — the caddy lesson (kit PR 293, admin.ts's own ⛔): merging a layer that still
11
+ * requires `HttpClient.HttpClient` into a stack's `providers` would let this module's own
12
+ * `FetchHttpClient` shadow a sibling fetch-based provider's client once both are merged into the
13
+ * same `Layer.mergeAll`. `Layer.provide` (not `Layer.provideMerge`) twice below feeds
14
+ * `FetchHttpClient`/`OtlpSerialization` to the OTLP layers and returns ONLY their own output —
15
+ * `HttpClient.HttpClient` never appears in what `telemetryLayer` hands back. See
16
+ * http-client-sealing.test.ts.
17
+ * ★ PER-SIGNAL URLS, NOT ONE BASE URL. effect's own `Otlp.layer`/`layerJson` take a single
18
+ * `baseUrl` and post to `<baseUrl>/v1/{traces,metrics,logs}` — fine when one collector serves all
19
+ * three, wrong for Victoria's split binaries (VictoriaTraces/Logs/Metrics on three different
20
+ * ports/paths — docs/telemetry.md has the exact paths). `OtlpTracer`/`OtlpLogger`/`OtlpMetrics`
21
+ * each take their own `url` and post to it verbatim (OtlpExporter.ts `make`: `HttpClientRequest.
22
+ * post(options.url, …)`, no suffix appended) — exactly the shape three independent endpoints need.
23
+ * ⛔ PROTOBUF, NOT JSON (redteam PR 302 CRITICAL item 1, 2026-09-26, measured against the pinned
24
+ * binaries in homeflare-mini's `src/obs`): VictoriaLogs 1.52.0 and VictoriaMetrics 1.151.0 both
25
+ * answer OTLP/HTTP JSON with `400 "json encoding isn't supported for opentelemetry format"` —
26
+ * effect's exporter treats a 400 as non-transient, drops the batch silently and disables itself for
27
+ * 60s at debug-only log level, so a JSON-encoded consumer sees traces (VictoriaTraces alone
28
+ * tolerates JSON) but never logs or metrics, with no visible error. `redactedSerialization` below
29
+ * provides `OtlpSerialization.layerProtobuf`, which all three ingested in the same measurement.
30
+ */
31
+ import type * as Duration from 'effect/Duration';
32
+ import * as Effect from 'effect/Effect';
33
+ import * as Layer from 'effect/Layer';
34
+ import * as Logger from 'effect/Logger';
35
+ import * as LogLevel from 'effect/LogLevel';
36
+ import * as Tracer from 'effect/Tracer';
37
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
38
+ import { OtlpExporter, OtlpLogger, OtlpMetrics, OtlpTracer } from 'effect/unstable/observability';
39
+ import { redactedSerialization } from './redact-serialization.ts';
40
+ import { type RedactionPolicy, wrapTracer } from './redact.ts';
41
+
42
+ /** Full OTLP/HTTP endpoint per signal — e.g. `http://victoria.example.test:10428/insert/opentelemetry/v1/traces`. Any entry left `undefined` sends nothing for that signal. */
43
+ export interface TelemetryEndpoints {
44
+ readonly traces?: string;
45
+ readonly logs?: string;
46
+ readonly metrics?: string;
47
+ }
48
+
49
+ export interface TelemetryOptions {
50
+ readonly endpoints: TelemetryEndpoints;
51
+ /** OTLP `service.name` — required, since an unnamed service in a shared Victoria instance is unusable. */
52
+ readonly serviceName: string;
53
+ readonly serviceVersion?: string;
54
+ /** Extra OTLP resource attributes (e.g. `{ "deployment.environment": "prod" }`). */
55
+ readonly resourceAttributes?: Record<string, unknown>;
56
+ readonly redaction?: RedactionPolicy;
57
+ /**
58
+ * How often a signal flushes its buffer.
59
+ * @default '1 second' — short on purpose: alchemy invocations are CLI runs, often shorter than a
60
+ * typical OTLP batch interval, so the export must fire well before the process could exit. The
61
+ * scope's own shutdown flush (`Exporter.layerFlusher`) is the backstop for whatever this interval
62
+ * misses; docs/telemetry-spike.md measured both paths.
63
+ */
64
+ readonly exportInterval?: Duration.Input;
65
+ /**
66
+ * Floor below which a log record never reaches the OTLP logger.
67
+ * @default 'Info' — redteam PR 302 IMPORTANT item 4: the `alchemy` CLI sets the GLOBAL minimum
68
+ * log level to Debug (`GlobalLog.ts`), and `OtlpLogger` has no floor of its own, so without one
69
+ * every Debug-level record (a provider's own connection-string/config dump, annotation noise)
70
+ * would ship — once `redactedSerialization` fixed item 1's silent-JSON-failure, they would
71
+ * actually start arriving. This floor applies ONLY to what THIS layer exports; a consumer's
72
+ * console/file loggers keep whatever level the CLI's own global setting gives them, since
73
+ * `logsMinimumLevel` filters after `Logger.layer`'s `mergeWithExisting: true` has already added
74
+ * this logger alongside them, not by lowering that global setting.
75
+ */
76
+ readonly logsMinimumLevel?: LogLevel.LogLevel;
77
+ }
78
+
79
+ // ⚠️ `exactOptionalPropertyTypes` — omit a key entirely rather than set it `undefined`, or the
80
+ // OTLP layers' own `{ readonly attributes?: X }` (no `| undefined` in their own signatures)
81
+ // rejects the object.
82
+ const resourceOf = (options: TelemetryOptions) => ({
83
+ serviceName: options.serviceName,
84
+ ...(options.serviceVersion !== undefined ? { serviceVersion: options.serviceVersion } : {}),
85
+ ...(options.resourceAttributes !== undefined ? { attributes: options.resourceAttributes } : {}),
86
+ });
87
+
88
+ /**
89
+ * `OtlpTracer.layer`'s own composition (OtlpTracer.ts: `flow(make, Layer.effect(Tracer.Tracer),
90
+ * Layer.provideMerge(Exporter.layerFlusher))`), with `wrapTracer` inserted between `make` and
91
+ * `Layer.effect` so every span this tracer creates is redacted before anything else can see it.
92
+ */
93
+ const tracerLayer = (url: string, options: TelemetryOptions, exportInterval: Duration.Input) =>
94
+ Layer.effect(
95
+ Tracer.Tracer,
96
+ Effect.map(OtlpTracer.make({ exportInterval, resource: resourceOf(options), url }), (inner) =>
97
+ wrapTracer(inner, options.redaction ?? {}),
98
+ ),
99
+ ).pipe(Layer.provideMerge(OtlpExporter.layerFlusher));
100
+
101
+ /**
102
+ * `OtlpLogger.layer`'s own composition (`Logger.layer([make(options)], { mergeWithExisting: true })
103
+ * .pipe(Layer.provideMerge(Exporter.layerFlusher))`), with a level floor inserted between `make` and
104
+ * `Logger.layer` — see `TelemetryOptions.logsMinimumLevel`'s own doc for why.
105
+ */
106
+ const loggerLayer = (url: string, options: TelemetryOptions, exportInterval: Duration.Input) => {
107
+ const floor = options.logsMinimumLevel ?? 'Info';
108
+ return Logger.layer(
109
+ [
110
+ Effect.map(OtlpLogger.make({ exportInterval, resource: resourceOf(options), url }), (inner) =>
111
+ Logger.make((record) =>
112
+ LogLevel.isGreaterThanOrEqualTo(record.logLevel, floor) ? inner.log(record) : undefined,
113
+ ),
114
+ ),
115
+ ],
116
+ { mergeWithExisting: true },
117
+ ).pipe(Layer.provideMerge(OtlpExporter.layerFlusher));
118
+ };
119
+
120
+ /**
121
+ * The Layer a consumer stack merges into its own `providers` — see docs/telemetry.md for how a
122
+ * site file wires real hosts in, and docs/telemetry-spike.md for what a stack's `providers` Layer does and does
123
+ * not carry a span for.
124
+ *
125
+ * ⚠️ FIXED THREE-ARG `Layer.mergeAll`, NOT AN ARRAY `.reduce`. Every signal slot is filled — with
126
+ * the real per-signal layer when its endpoint is set, `Layer.empty` when it is not — instead of
127
+ * building a variable-length array and folding it with `Layer.merge`. `Layer<in ROut, …>`
128
+ * declares its success channel CONTRAVARIANT, and once a fold's accumulator needs an explicit
129
+ * annotation to type-check under this repo's `exactOptionalPropertyTypes`, TS's overload
130
+ * resolution for `Layer.provide`/`Layer.merge` starts inferring `unknown` for that channel and
131
+ * then fails `unknown` against `never` (measured 2026-09-26, TS2769). `Layer.mergeAll`'s own
132
+ * fixed-arity overload — the exact shape alchemy's own `Telemetry/Layer.ts` uses for its three
133
+ * signals — infers cleanly with no annotation at all.
134
+ */
135
+ export const telemetryLayer = (options: TelemetryOptions): Layer.Layer<never> => {
136
+ const { traces, logs, metrics } = options.endpoints;
137
+ if (traces === undefined && logs === undefined && metrics === undefined) return Layer.empty;
138
+ // Default declared here, not in the options' type, so every call site above stays a concrete
139
+ // `Duration.Input` — `exactOptionalPropertyTypes` rejects passing `undefined` through to the
140
+ // OTLP layers' own `exportInterval?: Duration.Input` (no `| undefined` in their signatures).
141
+ const exportInterval: Duration.Input = options.exportInterval ?? '1 second';
142
+ const tracer = traces === undefined ? Layer.empty : tracerLayer(traces, options, exportInterval);
143
+ const logger = logs === undefined ? Layer.empty : loggerLayer(logs, options, exportInterval);
144
+ const meter =
145
+ metrics === undefined
146
+ ? Layer.empty
147
+ : OtlpMetrics.layer({ exportInterval, resource: resourceOf(options), url: metrics });
148
+ return Layer.mergeAll(tracer, logger, meter).pipe(
149
+ Layer.provide(redactedSerialization(options.redaction ?? {})),
150
+ Layer.provide(FetchHttpClient.layer),
151
+ );
152
+ };
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Free-text OTLP redaction applied at `OtlpSerialization` — the ONE place every span/log field
3
+ * reaches the wire, right before HTTP body encoding. `redact.ts`'s `wrapTracer` only sees what a
4
+ * caller passes to `Span.attribute()`/`Span.event()`; two real fields never go through either hook:
5
+ *
6
+ * - `status.message` and the `exception.*` event attributes `OtlpTracer`'s own `makeOtlpSpan` builds
7
+ * from `Cause.prettyErrors(exit.cause)` — computed at export time, inside effect's tracer, AFTER
8
+ * `wrapTracer.end()` has already handed the exit straight to `real.end()`.
9
+ * - a log line inside a span, turned into a span event by effect's default `tracerLogger` — the log
10
+ * message becomes the event NAME (not an attribute `wrapTracer.event()` redacts), and its
11
+ * `effect.cause` attribute is free text `redactAttribute`'s key-based rules never recognize.
12
+ *
13
+ * Redteam finding (PR 302, 2026-09-26): a provider GET that 404'd exported `exception.message =
14
+ * "... 404 GET http://host/secret-site-abc123/probe?token=SECRET123"` unredacted — the query, the
15
+ * denylisted path segment and the denylisted host all went out. A `logWarning` inside a span leaked
16
+ * the same way through the event name.
17
+ *
18
+ * ⚠️ SUBSTRING MATCH, NOT SEGMENT MATCH. `redactAttribute` compares a denylist entry against a whole
19
+ * `/`-bounded URL segment on purpose (`redact.ts`'s own ⚠️: `"db"` must not blank `"db1.example.
20
+ * test"`). Free text has no segment boundaries — a log message or a formatted `Cause` embeds a
21
+ * token in prose — so `scrubText` matches a denylist entry as a literal substring ANYWHERE in the
22
+ * string. This is strictly broader than the structured rules, so it also closes the `server.
23
+ * address` bare-hostname and legacy `http.url` gaps (redteam MINOR item 5) as a side effect: a
24
+ * denylisted hostname is a substring match wherever it appears, URL-shaped or not. The tradeoff a
25
+ * caller accepts by naming a short, common word in `denylist` is over-redaction, never a leak.
26
+ */
27
+ import * as Effect from 'effect/Effect';
28
+ import * as Layer from 'effect/Layer';
29
+ import { OtlpSerialization } from 'effect/unstable/observability';
30
+ import type { OtlpLogger, OtlpResource, OtlpTracer } from 'effect/unstable/observability';
31
+ import { REDACTED, type RedactionPolicy } from './redact.ts';
32
+
33
+ const URL_PATTERN = /https?:\/\/[^\s"'<>)]+/g;
34
+
35
+ /** Strips an embedded URL's query/hash and blanks its denylisted host/path segments — `redact.ts`'s
36
+ * `url.full` case, reused here because a URL can appear inside prose, not just as its own attribute. */
37
+ const scrubUrl = (raw: string, denylist: ReadonlySet<string>): string => {
38
+ try {
39
+ const url = new URL(raw);
40
+ const host = denylist.has(url.hostname) ? REDACTED : url.host;
41
+ const path = url.pathname
42
+ .split('/')
43
+ .map((segment) => (denylist.has(segment) ? REDACTED : segment))
44
+ .join('/');
45
+ return `${url.protocol}//${host}${path}`;
46
+ } catch {
47
+ return raw; // Matched the URL_PATTERN regex but `URL` still rejects it — leave verbatim.
48
+ }
49
+ };
50
+
51
+ /** Redacts one piece of free text: every embedded URL loses its query string and denylisted
52
+ * segments, then every denylist entry is blanked as a substring anywhere else in the text. */
53
+ export const scrubText = (text: string, policy: RedactionPolicy): string => {
54
+ const denylist = policy.denylist ?? [];
55
+ const denylistSet = new Set(denylist);
56
+ let out = text.replace(URL_PATTERN, (match) => scrubUrl(match, denylistSet));
57
+ for (const token of denylist) {
58
+ if (token.length === 0) continue; // an empty entry would blank every character boundary
59
+ out = out.split(token).join(REDACTED);
60
+ }
61
+ return out;
62
+ };
63
+
64
+ /** Walks one OTLP `AnyValue`'s string leaves (`stringValue`, and recursively through `arrayValue`/
65
+ * `kvlistValue`) — the union effect's OTLP encoders use for every attribute and log body value. */
66
+ const scrubValue = (
67
+ value: OtlpResource.AnyValue,
68
+ policy: RedactionPolicy,
69
+ ): OtlpResource.AnyValue => {
70
+ if (typeof value.stringValue === 'string')
71
+ return { ...value, stringValue: scrubText(value.stringValue, policy) };
72
+ if (value.arrayValue !== undefined) {
73
+ return {
74
+ ...value,
75
+ arrayValue: { values: value.arrayValue.values.map((v) => scrubValue(v, policy)) },
76
+ };
77
+ }
78
+ if (value.kvlistValue !== undefined) {
79
+ return {
80
+ ...value,
81
+ kvlistValue: {
82
+ values: value.kvlistValue.values.map((kv) => ({
83
+ key: kv.key,
84
+ value: scrubValue(kv.value, policy),
85
+ })),
86
+ },
87
+ };
88
+ }
89
+ return value; // boolValue / intValue / doubleValue / bytesValue — never free text.
90
+ };
91
+
92
+ const scrubKeyValues = (
93
+ attributes: ReadonlyArray<OtlpResource.KeyValue>,
94
+ policy: RedactionPolicy,
95
+ ): Array<OtlpResource.KeyValue> =>
96
+ attributes.map((kv) => ({ key: kv.key, value: scrubValue(kv.value, policy) }));
97
+
98
+ /** `TraceData` after every span's name, attributes, events (including the `exception.*` ones
99
+ * `makeOtlpSpan` builds from the exit), status message and link attributes have been scrubbed. */
100
+ export const redactTraceData = (
101
+ data: OtlpTracer.TraceData,
102
+ policy: RedactionPolicy,
103
+ ): OtlpTracer.TraceData => ({
104
+ resourceSpans: data.resourceSpans.map((resourceSpan) => ({
105
+ ...resourceSpan,
106
+ scopeSpans: resourceSpan.scopeSpans.map((scopeSpan) => ({
107
+ ...scopeSpan,
108
+ spans: scopeSpan.spans.map((span) => ({
109
+ ...span,
110
+ attributes: scrubKeyValues(span.attributes, policy),
111
+ events: span.events.map((event) => ({
112
+ ...event,
113
+ attributes: scrubKeyValues(event.attributes, policy),
114
+ name: scrubText(event.name, policy),
115
+ })),
116
+ links: span.links.map((link) => ({
117
+ ...link,
118
+ attributes: scrubKeyValues(link.attributes, policy),
119
+ })),
120
+ name: scrubText(span.name, policy),
121
+ status:
122
+ span.status.message === undefined
123
+ ? span.status
124
+ : { ...span.status, message: scrubText(span.status.message, policy) },
125
+ })),
126
+ })),
127
+ })),
128
+ });
129
+
130
+ type LogRecord = NonNullable<
131
+ OtlpLogger.LogsData['resourceLogs'][number]['scopeLogs'][number]['logRecords']
132
+ >[number];
133
+
134
+ // ⚠️ `exactOptionalPropertyTypes` — `body` is `AnyValue?` with no `| undefined` in its own
135
+ // signature (matches `layer.ts`'s own `resourceOf` note), so an absent one is OMITTED, not set to
136
+ // `undefined`, or this object-literal return fails to type-check against `ILogRecord`.
137
+ const scrubLogRecord = (record: LogRecord, policy: RedactionPolicy): LogRecord => ({
138
+ ...record,
139
+ attributes: scrubKeyValues(record.attributes, policy),
140
+ ...(record.body !== undefined ? { body: scrubValue(record.body, policy) } : {}),
141
+ });
142
+
143
+ /** `LogsData` after every log record's attributes and body have been scrubbed — the fix for redteam
144
+ * item 4's "logs ship unredacted": `log.error` (`Cause.pretty`) and the message body are free text. */
145
+ export const redactLogsData = (
146
+ data: OtlpLogger.LogsData,
147
+ policy: RedactionPolicy,
148
+ ): OtlpLogger.LogsData => ({
149
+ resourceLogs: data.resourceLogs.map((resourceLog) => ({
150
+ ...resourceLog,
151
+ scopeLogs: resourceLog.scopeLogs.map((scopeLog) => ({
152
+ ...scopeLog,
153
+ // Same `exactOptionalPropertyTypes` reasoning as `scrubLogRecord` — omit `logRecords`
154
+ // entirely rather than assign it `undefined` when the source had none.
155
+ ...(scopeLog.logRecords !== undefined
156
+ ? { logRecords: scopeLog.logRecords.map((record) => scrubLogRecord(record, policy)) }
157
+ : {}),
158
+ })),
159
+ })),
160
+ });
161
+
162
+ /**
163
+ * Wraps the real `OtlpSerialization` so `telemetryLayer` gets this free-text pass on every signal
164
+ * with no separate wiring at each call site — `layer.ts` provides this in place of a bare
165
+ * `OtlpSerialization.layerProtobuf`. Metrics pass through untouched: they carry numeric data points
166
+ * keyed by `Metric.Metric.AttributeSet`, not provider-supplied free text.
167
+ */
168
+ export const redactedSerialization = (
169
+ policy: RedactionPolicy,
170
+ ): Layer.Layer<OtlpSerialization.OtlpSerialization> =>
171
+ Layer.effect(
172
+ OtlpSerialization.OtlpSerialization,
173
+ Effect.map(OtlpSerialization.OtlpSerialization, (inner) => ({
174
+ logs: (data: OtlpLogger.LogsData) => inner.logs(redactLogsData(data, policy)),
175
+ metrics: inner.metrics,
176
+ traces: (data: OtlpTracer.TraceData) => inner.traces(redactTraceData(data, policy)),
177
+ })),
178
+ ).pipe(Layer.provide(OtlpSerialization.layerProtobuf));
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Pure-function coverage for `redactAttribute`/`wrapTracer`. layer.test.ts covers the same policy
3
+ * end to end, through a real `HttpClient` call and a real OTLP export — this file is the fast,
4
+ * exhaustive sweep of the redaction rules themselves.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import * as Option from 'effect/Option';
8
+ import type * as Tracer from 'effect/Tracer';
9
+ import { DROP, REDACTED, redactAttribute, wrapTracer } from './redact.ts';
10
+
11
+ describe('redactAttribute', () => {
12
+ test('drops every header attribute outright, request or response, regardless of value', () => {
13
+ expect(redactAttribute('http.request.header.authorization', 'Bearer x', {})).toBe(DROP);
14
+ expect(redactAttribute('http.request.header.x-api-key', 'k', {})).toBe(DROP);
15
+ expect(redactAttribute('http.response.header.set-cookie', 'a=b', {})).toBe(DROP);
16
+ });
17
+
18
+ test('drops url.query outright — no per-parameter allowlist', () => {
19
+ expect(redactAttribute('url.query', 'token=SECRET&safe=1', {})).toBe(DROP);
20
+ });
21
+
22
+ test('strips the query string and hash from url.full even with an empty policy', () => {
23
+ expect(redactAttribute('url.full', 'http://host/path?token=SECRET#frag', {})).toBe(
24
+ 'http://host/path',
25
+ );
26
+ });
27
+
28
+ test('leaves url.full, url.path and server.address untouched when nothing matches the denylist', () => {
29
+ expect(redactAttribute('url.full', 'http://host:9428/insert/opentelemetry/v1/traces', {})).toBe(
30
+ 'http://host:9428/insert/opentelemetry/v1/traces',
31
+ );
32
+ expect(redactAttribute('url.path', '/insert/opentelemetry/v1/traces', {})).toBe(
33
+ '/insert/opentelemetry/v1/traces',
34
+ );
35
+ expect(redactAttribute('server.address', 'http://host:9428', {})).toBe('http://host:9428');
36
+ });
37
+
38
+ test('blanks a denylisted path segment, exact match only — no substring match', () => {
39
+ const policy = { denylist: ['console-abc123'] };
40
+ expect(redactAttribute('url.path', '/proxy/network/console-abc123/site', policy)).toBe(
41
+ `/proxy/network/${REDACTED}/site`,
42
+ );
43
+ // "console-abc123-extra" must NOT match the denylisted "console-abc123" segment.
44
+ expect(redactAttribute('url.path', '/proxy/console-abc123-extra', policy)).toBe(
45
+ '/proxy/console-abc123-extra',
46
+ );
47
+ });
48
+
49
+ test('blanks a denylisted host in both url.full and server.address', () => {
50
+ const policy = { denylist: ['private.example.test'] };
51
+ expect(redactAttribute('server.address', 'https://private.example.test:443', policy)).toBe(
52
+ REDACTED,
53
+ );
54
+ expect(redactAttribute('url.full', 'https://private.example.test/api/site/x?y=1', policy)).toBe(
55
+ `https://${REDACTED}/api/site/x`,
56
+ );
57
+ });
58
+
59
+ test('a non-string value (e.g. server.port) and an unrecognized key pass through untouched', () => {
60
+ expect(redactAttribute('server.port', 8428, { denylist: ['8428'] })).toBe(8428);
61
+ expect(redactAttribute('alchemy.resource.fqn', 'stack/thing', { denylist: ['thing'] })).toBe(
62
+ 'stack/thing',
63
+ );
64
+ });
65
+
66
+ test('a url.full value that fails to parse as a URL is left verbatim, not guessed at', () => {
67
+ expect(redactAttribute('url.full', 'not a url', { denylist: ['not'] })).toBe('not a url');
68
+ });
69
+ });
70
+
71
+ const testSpanOptions = {
72
+ annotations: undefined as never,
73
+ kind: 'internal' as const,
74
+ links: [],
75
+ name: 'test',
76
+ parent: Option.none(),
77
+ root: true,
78
+ sampled: true,
79
+ startTime: 0n,
80
+ };
81
+
82
+ const recordingInner = (): { calls: Array<[string, unknown]>; tracer: Tracer.Tracer } => {
83
+ const calls: Array<[string, unknown]> = [];
84
+ const tracer: Tracer.Tracer = {
85
+ span: (options) => ({
86
+ _tag: 'Span',
87
+ addLinks: () => {},
88
+ annotations: options.annotations,
89
+ attribute: (key, value) => calls.push([key, value]),
90
+ attributes: new Map(),
91
+ end: () => {},
92
+ event: () => {},
93
+ kind: options.kind,
94
+ links: options.links,
95
+ name: options.name,
96
+ parent: options.parent,
97
+ sampled: options.sampled,
98
+ spanId: 'span-1',
99
+ status: { _tag: 'Started', startTime: options.startTime },
100
+ traceId: 'trace-1',
101
+ }),
102
+ };
103
+ return { calls, tracer };
104
+ };
105
+
106
+ describe('wrapTracer', () => {
107
+ test('forwards an allowed attribute and withholds a dropped one from the real span', () => {
108
+ const { calls, tracer } = recordingInner();
109
+ const wrapped = wrapTracer(tracer, {});
110
+ const span = wrapped.span(testSpanOptions);
111
+ span.attribute('http.request.header.cookie', 'secret');
112
+ span.attribute('alchemy.resource.fqn', 'stack/thing');
113
+ expect(calls).toEqual([['alchemy.resource.fqn', 'stack/thing']]);
114
+ });
115
+
116
+ test("passes the span's identity and lifecycle through unchanged", () => {
117
+ const { tracer } = recordingInner();
118
+ const wrapped = wrapTracer(tracer, {});
119
+ const span = wrapped.span(testSpanOptions);
120
+ expect(span.name).toBe('test');
121
+ expect(span.spanId).toBe('span-1');
122
+ expect(span.traceId).toBe('trace-1');
123
+ });
124
+ });