1import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { describe, expect, it } from "@effect/vitest";
4import { RelayApi } from "@t3tools/contracts/relay";
5import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";
8import * as Layer from "effect/Layer";
9import * as Redacted from "effect/Redacted";
10import * as Schema from "effect/Schema";
11import * as Etag from "effect/http/Etag";
12import * as HttpClient from "effect/http/HttpClient";
13import type * as HttpClientError from "effect/http/HttpClientError";
14import type * as HttpClientRequest from "effect/http/HttpClientRequest";
15import * as HttpClientResponse from "effect/http/HttpClientResponse";
16import * as HttpEffect from "effect/http/HttpEffect";
17import * as HttpRouter from "effect/http/HttpRouter";
18import * as HttpServerRequest from "effect/http/HttpServerRequest";
19import * as HttpServerResponse from "effect/http/HttpServerResponse";
20import * as HttpApi from "effect/http-api/HttpApi";
21import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
22
23import { RELAY_HTTP_ROUTER_CONFIG } from "../http/Api.ts";
24import * as RelayHttpApi from "../http/Api.ts";
25import * as MobileTelemetry from "./MobileTelemetry.ts";
26
27const PROJECT_ID = "d763fcb8-d37c-41ea-a773-b54a0ab4a454";
28const UPDATE_ID = "0198e8a4-1111-7000-8000-000000000001";
29const BUILD_ID = "0198e8a4-2222-7000-8000-000000000002";
30const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
31const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
32
33const destinations = {
34 traces: { url: "https://axiom.test/v1/traces", dataset: "mobile-traces" },
35 logs: { url: "https://axiom.test/v1/logs", dataset: "mobile-logs" },
36 metrics: { url: "https://axiom.test/v1/metrics", dataset: "mobile-metrics" },
37} as const;
38
39/** Maps generated column 12 of the one-line bundle to `src/App.tsx` 1:2. */
40const SOURCE_MAP = encodeJson({ version: 3, sources: ["src/App.tsx"], mappings: "AAAA,UAAE" });
41const STACK = "Error: x\n at render (address at index.android.bundle:1:12)";
42
43interface Harness {
44 readonly execute?: (
45 request: HttpClientRequest.HttpClientRequest,
46 ) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>;
47 /** The source maps bucket, by key. */
48 readonly objects?: Readonly<Record<string, string>>;
49}
50
51const makeHarness = (options: Harness = {}) => {
52 const sent: Array<HttpClientRequest.HttpClientRequest> = [];
53 const execute =
54 options.execute ??
55 ((request: HttpClientRequest.HttpClientRequest) =>
56 Effect.succeed(
57 HttpClientResponse.fromWeb(
58 request,
59 new Response("{}", { status: 200, headers: { "content-type": "application/json" } }),
60 ),
61 ));
62 const layerTelemetry = MobileTelemetry.layer.pipe(
63 Layer.provide(
64 Layer.mergeAll(
65 Layer.succeed(MobileTelemetry.MobileTelemetrySettings, {
66 axiomToken: Redacted.make("axiom-ingest-token"),
67 destinations,
68 }),
69 Layer.succeed(MobileTelemetry.MobileTelemetryRateLimiter, {
70 allow: () => Effect.succeed(true),
71 }),
72 MobileTelemetry.layerSourceMaps.pipe(
73 Layer.provide(
74 Layer.succeed(MobileTelemetry.SourceMapObjects, {
75 get: (key) =>
76 Effect.sync(() => {
77 const object = options.objects?.[key];
78 return object === undefined
79 ? undefined
80 : { size: object.length, text: Effect.succeed(object) };
81 }),
82 }),
83 ),
84 ),
85 Layer.succeed(
86 HttpClient.HttpClient,
87 HttpClient.make((request) => {
88 sent.push(request);
89 return execute(request);
90 }),
91 ),
92 ),
93 ),
94 );
95 const httpEffect = HttpRouter.toHttpEffect(
96 Layer.mergeAll(
97 HttpApiBuilder.layer(HttpApi.make("RelayApi").add(RelayApi.groups.mobileTelemetry)).pipe(
98 Layer.provide(MobileTelemetry.layerApi.pipe(Layer.provide(layerTelemetry))),
99 Layer.provide([NodeServices.layer, NodeHttpPlatform.layer, Etag.layerWeak]),
100 ),
101 RelayHttpApi.layerNotFoundRoute,
102 ),
103 ).pipe(Effect.provideService(HttpRouter.RouterConfig, RELAY_HTTP_ROUTER_CONFIG));
104 const send = (request: Request) =>
105 Effect.gen(function* () {
106 const handler = yield* httpEffect;
107 const response = yield* Deferred.make<HttpServerResponse.HttpServerResponse>();
108 yield* HttpEffect.toHandled(handler, (_request, value) =>
109 Deferred.succeed(response, value),
110 ).pipe(
111 Effect.provideService(
112 HttpServerRequest.HttpServerRequest,
113 HttpServerRequest.fromWeb(request),
114 ),
115 );
116 return yield* Deferred.await(response);
117 });
118 return { sent, send };
119};
120
121const ingest = (signal: string, body: string) =>
122 new Request(`https://relay.test/v1/mobile/observe/${PROJECT_ID}/v1/${signal}`, {
123 method: "POST",
124 headers: { "content-type": "application/json", "cf-connecting-ip": "203.0.113.7" },
125 body,
126 });
127
128const sentText = (request: HttpClientRequest.HttpClientRequest) =>
129 request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "";
130
131const str = (key: string, value: string) => ({ key, value: { stringValue: value } });
132
133/** One resource's JS exception, as Observe reports it from the app's bundle. */
134const exceptionResource = (resource: ReadonlyArray<ReturnType<typeof str>>) => ({
135 resource: { attributes: resource },
136 scopeLogs: [
137 {
138 logRecords: [
139 {
140 attributes: [str("expo.error.source", "global"), str("exception.stacktrace", STACK)],
141 },
142 ],
143 },
144 ],
145});
146
147/** Each forwarded exception record's string attributes. */
148const forwardedExceptions = (request: HttpClientRequest.HttpClientRequest) => {
149 const payload = decodeJson(sentText(request)) as {
150 resourceLogs: Array<{
151 scopeLogs: Array<{
152 logRecords: Array<{ attributes: Array<{ key: string; value: { stringValue: string } }> }>;
153 }>;
154 }>;
155 };
156 return payload.resourceLogs.map((resourceLogs) =>
157 Object.fromEntries(
158 resourceLogs.scopeLogs[0]!.logRecords[0]!.attributes.map(({ key, value }) => [
159 key,
160 value.stringValue,
161 ]),
162 ),
163 );
164};
165
166describe("MobileTelemetry", () => {
167 it.effect("forwards each signal to its Axiom dataset with the relay's token", () =>
168 Effect.gen(function* () {
169 const harness = makeHarness();
170 const body = encodeJson({ resourceMetrics: [] });
171 for (const signal of ["metrics", "traces", "logs"] as const) {
172 const response = yield* harness.send(ingest(signal, body));
173 expect(response.status).toBe(200);
174 }
175 expect(
176 harness.sent.map((request) => ({
177 url: request.url,
178 authorization: request.headers.authorization,
179 dataset: request.headers["x-axiom-dataset"],
180 body: sentText(request),
181 })),
182 ).toEqual(
183 (["metrics", "traces", "logs"] as const).map((signal) => ({
184 url: destinations[signal].url,
185 authorization: "Bearer axiom-ingest-token",
186 dataset: destinations[signal].dataset,
187 body,
188 })),
189 );
190 }),
191 );
192
193 it.effect("passes Axiom's status and Retry-After back for Observe's retries", () =>
194 Effect.gen(function* () {
195 const harness = makeHarness({
196 execute: (request) =>
197 Effect.succeed(
198 HttpClientResponse.fromWeb(
199 request,
200 new Response('{"message":"slow down"}', {
201 status: 429,
202 headers: { "content-type": "application/json", "retry-after": "30" },
203 }),
204 ),
205 ),
206 });
207 const response = yield* harness.send(ingest("metrics", "{}"));
208 expect(response.status).toBe(429);
209 expect(response.headers["retry-after"]).toBe("30");
210 }),
211 );
212
213 it.effect("symbolicates JS exceptions with the OTA update's map, else the build's", () =>
214 Effect.gen(function* () {
215 const harness = makeHarness({
216 objects: {
217 [`android/ota/${UPDATE_ID}.map`]: SOURCE_MAP,
218 [`ios/build/${BUILD_ID}.map`]: SOURCE_MAP,
219 },
220 });
221 const body = encodeJson({
222 resourceLogs: [
223 exceptionResource([
224 str("os.type", "linux"),
225 str("expo.app.update_id", UPDATE_ID),
226 str("expo.eas_build.id", BUILD_ID),
227 ]),
228 exceptionResource([str("os.type", "darwin"), str("expo.eas_build.id", BUILD_ID)]),
229 ],
230 });
231 expect((yield* harness.send(ingest("logs", body))).status).toBe(200);
232 const symbolicated = (key: string) => ({
233 "expo.error.source": "global",
234 "exception.stacktrace": "Error: x\n at render (src/App.tsx:1:3)",
235 "relay.symbolication.source_map": key,
236 "relay.symbolication.status": "symbolicated",
237 "relay.symbolication.raw_stacktrace": STACK,
238 });
239 expect(forwardedExceptions(harness.sent[0]!)).toEqual([
240 symbolicated(`android/ota/${UPDATE_ID}.map`),
241 symbolicated(`ios/build/${BUILD_ID}.map`),
242 ]);
243 }),
244 );
245
246 it.effect("forwards the stack untouched when the OTA update's map is missing", () =>
247 Effect.gen(function* () {
248 // The build's map describes the embedded bundle, not the update that threw.
249 const harness = makeHarness({
250 objects: { [`android/build/${BUILD_ID}.map`]: SOURCE_MAP },
251 });
252 const body = encodeJson({
253 resourceLogs: [
254 exceptionResource([
255 str("os.type", "linux"),
256 str("expo.app.update_id", UPDATE_ID),
257 str("expo.eas_build.id", BUILD_ID),
258 ]),
259 ],
260 });
261 expect((yield* harness.send(ingest("logs", body))).status).toBe(200);
262 expect(forwardedExceptions(harness.sent[0]!)).toEqual([
263 {
264 "expo.error.source": "global",
265 "exception.stacktrace": STACK,
266 "relay.symbolication.status": "source_map_missing",
267 },
268 ]);
269 }),
270 );
271
272 it.effect("says why a stack stayed minified, without the iOS install path", () =>
273 Effect.gen(function* () {
274 const harness = makeHarness({ objects: { [`ios/build/${BUILD_ID}.map`]: "{}" } });
275 const iosStack =
276 "Error: x\n at render (address at /private/var/containers/Bundle/Application/" +
277 "0C1D/T3Code.app/main.jsbundle:1:12)";
278 const record = (stack: string) => ({
279 scopeLogs: [{ logRecords: [{ attributes: [str("exception.stacktrace", stack)] }] }],
280 });
281 const body = encodeJson({
282 resourceLogs: [
283 { resource: { attributes: [str("os.type", "linux")] }, ...record(STACK) },
284 {
285 resource: {
286 attributes: [str("os.type", "darwin"), str("expo.eas_build.id", BUILD_ID)],
287 },
288 ...record(iosStack),
289 },
290 ],
291 });
292 expect((yield* harness.send(ingest("logs", body))).status).toBe(200);
293 expect(forwardedExceptions(harness.sent[0]!)).toEqual([
294 { "exception.stacktrace": STACK, "relay.symbolication.status": "no_bundle_id" },
295 {
296 "exception.stacktrace": "Error: x\n at render (address at main.jsbundle:1:12)",
297 "relay.symbolication.status": "source_map_invalid",
298 },
299 ]);
300 }),
301 );
302
303 it.effect("marks the native abort that follows a fatal JS error in the same session", () =>
304 Effect.gen(function* () {
305 const harness = makeHarness();
306 const fatal = { key: "expo.error.is_fatal", value: { boolValue: true } };
307 const crash = (session: string, source: string) => ({
308 attributes: [str("expo.error.source", source), str("session.id", session), fatal],
309 });
310 const body = encodeJson({
311 resourceLogs: [
312 {
313 resource: { attributes: [str("os.type", "darwin")] },
314 scopeLogs: [
315 {
316 logRecords: [
317 crash("a", "global"),
318 crash("a", "nativeCrash"),
319 crash("b", "nativeCrash"),
320 ],
321 },
322 ],
323 },
324 ],
325 });
326 expect((yield* harness.send(ingest("logs", body))).status).toBe(200);
327 const records = (
328 decodeJson(sentText(harness.sent[0]!)) as {
329 resourceLogs: Array<{
330 scopeLogs: Array<{ logRecords: Array<{ attributes: Array<{ key: string }> }> }>;
331 }>;
332 }
333 ).resourceLogs[0]!.scopeLogs[0]!.logRecords;
334 expect(
335 records.map((record) =>
336 record.attributes.some((attribute) => attribute.key === "relay.crash.js_fatal"),
337 ),
338 ).toEqual([false, true, false]);
339 }),
340 );
341
342 it.effect("answers 413 for a body over the cap without forwarding it", () =>
343 Effect.gen(function* () {
344 const harness = makeHarness();
345 const tooLarge = "x".repeat(MobileTelemetry.MOBILE_TELEMETRY_MAX_BODY_BYTES + 1);
346 expect((yield* harness.send(ingest("logs", tooLarge))).status).toBe(413);
347 expect(harness.sent).toEqual([]);
348 }),
349 );
350
351 it.effect("decodes one source map at a time", () =>
352 Effect.gen(function* () {
353 const releaseFirst = yield* Deferred.make<void>();
354 const firstStarted = yield* Deferred.make<void>();
355 let firstDone = false;
356 let secondSawFirstDone: boolean | undefined;
357 const sourceMaps = yield* MobileTelemetry.SourceMaps.pipe(
358 Effect.provide(MobileTelemetry.layerSourceMaps),
359 Effect.provideService(MobileTelemetry.SourceMapObjects, {
360 get: (key) =>
361 key === "first"
362 ? Deferred.succeed(firstStarted, undefined).pipe(
363 Effect.andThen(Deferred.await(releaseFirst)),
364 Effect.as({ size: SOURCE_MAP.length, text: Effect.succeed(SOURCE_MAP) }),
365 Effect.ensuring(Effect.sync(() => (firstDone = true))),
366 )
367 : Effect.sync(() => {
368 secondSawFirstDone = firstDone;
369 return { size: SOURCE_MAP.length, text: Effect.succeed(SOURCE_MAP) };
370 }),
371 }),
372 );
373 const first = yield* Effect.forkChild(sourceMaps.find("first"));
374 yield* Deferred.await(firstStarted);
375 const second = yield* Effect.forkChild(sourceMaps.find("second"), { startImmediately: true });
376 yield* Deferred.succeed(releaseFirst, undefined);
377 yield* Fiber.join(first);
378 expect(yield* Fiber.join(second)).toMatchObject({ key: "second" });
379 expect(secondSawFirstDone).toBe(true);
380 }),
381 );
382});