Initial commit: code as received (Create/Anything export)
Insole-production time tracker exported from the Create/Anything AI platform. Baseline snapshot before any reverse-engineering or cleanup. - apps/mobile: Expo Router app (iOS/Android/web), the only workspace - publisher/: standalone OpenNext/AWS deploy tooling for the web side - Backend (/api/tasks, /api/logs + DB) lives remotely, not in this repo
This commit is contained in:
79
apps/mobile/__create/handle-resolve-request-error.js
Normal file
79
apps/mobile/__create/handle-resolve-request-error.js
Normal file
@@ -0,0 +1,79 @@
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { reportErrorToRemote } = require('./report-error-to-remote');
|
||||
|
||||
const VIRTUAL_ROOT = path.join(__dirname, '../.metro-virtual');
|
||||
const VIRTUAL_ROOT_UNRESOLVED = path.join(VIRTUAL_ROOT, 'unresolved');
|
||||
|
||||
const handleResolveRequestError = ({
|
||||
error,
|
||||
context,
|
||||
moduleName,
|
||||
platform,
|
||||
}) => {
|
||||
const errorMessage = `Unable to resolve module '${moduleName}' from '${context.originModulePath}'`;
|
||||
const syntheticError = new Error(errorMessage);
|
||||
syntheticError.stack = error.stack;
|
||||
reportErrorToRemote({ error: syntheticError }).catch((_reportError) => {
|
||||
// no-op
|
||||
});
|
||||
if (process.env.NODE_ENV === 'production') throw error;
|
||||
if (platform === 'android') throw error;
|
||||
if (!__DEV__ && process.env.EXPO_PUBLIC_CREATE_ENV !== 'DEVELOPMENT')
|
||||
throw error;
|
||||
|
||||
// Build a deterministic virtual file path for this failed request
|
||||
const key = `${moduleName}|${context.originModulePath}|${platform}`;
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(key)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
|
||||
fs.mkdirSync(VIRTUAL_ROOT_UNRESOLVED, { recursive: true });
|
||||
const vfile = path.join(VIRTUAL_ROOT_UNRESOLVED, `throw-${hash}.js`);
|
||||
|
||||
// Serialize a safe payload for the client
|
||||
const payload = {
|
||||
moduleName,
|
||||
from: context.originModulePath,
|
||||
platform,
|
||||
originalMessage: String(
|
||||
error?.message ? error.message : 'Unknown resolve error'
|
||||
),
|
||||
};
|
||||
|
||||
const code = [
|
||||
'// Auto generated by custom Metro resolver',
|
||||
'(function(){',
|
||||
` var info = ${JSON.stringify(payload)};`,
|
||||
" var msg = 'Unable to resolve \"' + info.moduleName + '\" from \"' + info.from + '\"';",
|
||||
" msg += '\\n\\n' + info.originalMessage;",
|
||||
' var e = new Error(msg);',
|
||||
" e.name = 'ModuleResolveError';",
|
||||
" e.code = 'MODULE_RESOLVE_FAILED';",
|
||||
' throw e;',
|
||||
'})();',
|
||||
'export {};', // keep ESM shape harmlessly
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
// Only write if content changed — avoids bumping mtime and triggering Metro rebuild loop
|
||||
const existingContent = fs.existsSync(vfile) ? fs.readFileSync(vfile, 'utf8') : null;
|
||||
if (existingContent !== code) {
|
||||
fs.writeFileSync(vfile, code, 'utf8');
|
||||
}
|
||||
|
||||
// Tell Metro to load our thrower as a real source file
|
||||
return {
|
||||
filePath: vfile,
|
||||
type: 'sourceFile',
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
handleResolveRequestError,
|
||||
VIRTUAL_ROOT,
|
||||
VIRTUAL_ROOT_UNRESOLVED,
|
||||
};
|
||||
53
apps/mobile/__create/report-error-to-remote.js
Normal file
53
apps/mobile/__create/report-error-to-remote.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { serializeError } from "serialize-error";
|
||||
|
||||
export const sendLogsToRemote = async (logs) => {
|
||||
if (
|
||||
!process.env.EXPO_PUBLIC_LOGS_ENDPOINT ||
|
||||
!process.env.EXPO_PUBLIC_PROJECT_GROUP_ID ||
|
||||
!process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY
|
||||
) {
|
||||
return { success: false };
|
||||
}
|
||||
try {
|
||||
const response = await fetch(process.env.EXPO_PUBLIC_LOGS_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectGroupId: process.env.EXPO_PUBLIC_PROJECT_GROUP_ID,
|
||||
logs,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return { success: false };
|
||||
}
|
||||
} catch (fetchError) {
|
||||
return { success: false, error: fetchError };
|
||||
}
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const reportErrorToRemote = async ({ error }) => {
|
||||
if (
|
||||
!process.env.EXPO_PUBLIC_LOGS_ENDPOINT ||
|
||||
!process.env.EXPO_PUBLIC_PROJECT_GROUP_ID ||
|
||||
!process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY
|
||||
) {
|
||||
console.debug(
|
||||
"reportErrorToRemote: Missing environment variables for logging endpoint, project group ID, or API key.",
|
||||
error,
|
||||
);
|
||||
return { success: false };
|
||||
}
|
||||
return sendLogsToRemote([
|
||||
{
|
||||
message: JSON.stringify(serializeError(error)),
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "error",
|
||||
source: "BUILDER",
|
||||
devServerId: process.env.EXPO_PUBLIC_DEV_SERVER_ID,
|
||||
},
|
||||
]);
|
||||
};
|
||||
115
apps/mobile/__create/report-error-to-remote.test.js
Normal file
115
apps/mobile/__create/report-error-to-remote.test.js
Normal file
@@ -0,0 +1,115 @@
|
||||
jest.mock("serialize-error", () => ({
|
||||
serializeError: jest.fn((err) => ({
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
name: err instanceof Error ? err.name : "Error",
|
||||
})),
|
||||
}));
|
||||
|
||||
let sendLogsToRemote;
|
||||
let reportErrorToRemote;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.resetModules();
|
||||
delete process.env.EXPO_PUBLIC_LOGS_ENDPOINT;
|
||||
delete process.env.EXPO_PUBLIC_PROJECT_GROUP_ID;
|
||||
delete process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY;
|
||||
delete process.env.EXPO_PUBLIC_DEV_SERVER_ID;
|
||||
global.fetch = jest.fn();
|
||||
|
||||
// Re-require after mocks are set up
|
||||
jest.doMock("serialize-error", () => ({
|
||||
serializeError: jest.fn((err) => ({
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
name: err instanceof Error ? err.name : "Error",
|
||||
})),
|
||||
}));
|
||||
const mod = require("./report-error-to-remote");
|
||||
sendLogsToRemote = mod.sendLogsToRemote;
|
||||
reportErrorToRemote = mod.reportErrorToRemote;
|
||||
});
|
||||
|
||||
describe("sendLogsToRemote", () => {
|
||||
it("returns success: false when env vars are missing", async () => {
|
||||
const result = await sendLogsToRemote([{ message: "test" }]);
|
||||
expect(result).toEqual({ success: false });
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends logs to the endpoint with correct auth header", async () => {
|
||||
process.env.EXPO_PUBLIC_LOGS_ENDPOINT = "https://logs.test/ingest";
|
||||
process.env.EXPO_PUBLIC_PROJECT_GROUP_ID = "pg-123";
|
||||
process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY = "key-abc";
|
||||
|
||||
global.fetch = jest.fn().mockResolvedValue({ ok: true });
|
||||
|
||||
const logs = [
|
||||
{ message: "hello", level: "info", timestamp: "2026-01-01T00:00:00Z" },
|
||||
];
|
||||
const result = await sendLogsToRemote(logs);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(global.fetch).toHaveBeenCalledWith("https://logs.test/ingest", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer key-abc",
|
||||
},
|
||||
body: JSON.stringify({ projectGroupId: "pg-123", logs }),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns success: false on non-ok response", async () => {
|
||||
process.env.EXPO_PUBLIC_LOGS_ENDPOINT = "https://logs.test/ingest";
|
||||
process.env.EXPO_PUBLIC_PROJECT_GROUP_ID = "pg-123";
|
||||
process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY = "key-abc";
|
||||
|
||||
global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 500 });
|
||||
|
||||
const result = await sendLogsToRemote([{ message: "fail" }]);
|
||||
expect(result).toEqual({ success: false });
|
||||
});
|
||||
|
||||
it("returns success: false with error on network failure", async () => {
|
||||
process.env.EXPO_PUBLIC_LOGS_ENDPOINT = "https://logs.test/ingest";
|
||||
process.env.EXPO_PUBLIC_PROJECT_GROUP_ID = "pg-123";
|
||||
process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY = "key-abc";
|
||||
|
||||
const networkError = new Error("Network request failed");
|
||||
global.fetch = jest.fn().mockRejectedValue(networkError);
|
||||
|
||||
const result = await sendLogsToRemote([{ message: "fail" }]);
|
||||
expect(result).toEqual({ success: false, error: networkError });
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportErrorToRemote", () => {
|
||||
it("returns success: false when env vars are missing", async () => {
|
||||
const result = await reportErrorToRemote({
|
||||
error: new Error("test error"),
|
||||
});
|
||||
expect(result).toEqual({ success: false });
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serializes error and sends as a single log entry with source BUILDER", async () => {
|
||||
process.env.EXPO_PUBLIC_LOGS_ENDPOINT = "https://logs.test/ingest";
|
||||
process.env.EXPO_PUBLIC_PROJECT_GROUP_ID = "pg-123";
|
||||
process.env.EXPO_PUBLIC_CREATE_TEMP_API_KEY = "key-abc";
|
||||
process.env.EXPO_PUBLIC_DEV_SERVER_ID = "ds-456";
|
||||
|
||||
global.fetch = jest.fn().mockResolvedValue({ ok: true });
|
||||
|
||||
const error = new Error("something broke");
|
||||
await reportErrorToRemote({ error });
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||
expect(body.projectGroupId).toBe("pg-123");
|
||||
expect(body.logs).toHaveLength(1);
|
||||
expect(body.logs[0].level).toBe("error");
|
||||
expect(body.logs[0].source).toBe("BUILDER");
|
||||
expect(body.logs[0].devServerId).toBe("ds-456");
|
||||
expect(body.logs[0].message).toContain("something broke");
|
||||
});
|
||||
});
|
||||
78
apps/mobile/__create/sentry.ts
Normal file
78
apps/mobile/__create/sentry.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
import { sendLogsToRemote } from "./report-error-to-remote";
|
||||
|
||||
function isActive(): boolean {
|
||||
return (
|
||||
!__DEV__ &&
|
||||
process.env.EXPO_PUBLIC_CREATE_ENV !== "DEVELOPMENT" &&
|
||||
!!process.env.EXPO_PUBLIC_SENTRY_DSN
|
||||
);
|
||||
}
|
||||
|
||||
let initialized = false;
|
||||
|
||||
// Mirror a Sentry event into the Anything logs pipeline so native and JS
|
||||
// crashes — including startup crashes that Sentry caches natively and reports
|
||||
// on the next launch — surface in the Flux builder, not only the Sentry
|
||||
// dashboard.
|
||||
function forwardEventToRemote(event: Sentry.Event): void {
|
||||
try {
|
||||
const exception = event.exception?.values?.[0];
|
||||
const lines: string[] = [];
|
||||
if (exception && (exception.type || exception.value)) {
|
||||
lines.push(`${exception.type ?? "Error"}: ${exception.value ?? ""}`);
|
||||
} else if (typeof event.message === "string") {
|
||||
lines.push(event.message);
|
||||
}
|
||||
const frames = exception?.stacktrace?.frames;
|
||||
if (frames && frames.length > 0) {
|
||||
lines.push(
|
||||
frames
|
||||
.slice(-20)
|
||||
.reverse()
|
||||
.map(
|
||||
(frame) =>
|
||||
` at ${frame.function ?? "?"} (${frame.filename ?? "?"}:${frame.lineno ?? 0})`,
|
||||
)
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
const message = lines.join("\n").trim();
|
||||
if (!message) return;
|
||||
const timestamp =
|
||||
typeof event.timestamp === "number"
|
||||
? new Date(event.timestamp * 1000).toISOString()
|
||||
: new Date().toISOString();
|
||||
sendLogsToRemote([
|
||||
{
|
||||
message: `[SENTRY] ${message}`,
|
||||
timestamp,
|
||||
level: "error",
|
||||
source: "TEST_FLIGHT",
|
||||
},
|
||||
]);
|
||||
} catch (_err) {
|
||||
// Silent
|
||||
}
|
||||
}
|
||||
|
||||
export function initSentry(): void {
|
||||
try {
|
||||
if (!isActive() || initialized) return;
|
||||
initialized = true;
|
||||
Sentry.init({
|
||||
dsn: process.env.EXPO_PUBLIC_SENTRY_DSN,
|
||||
enableNativeCrashHandling: true,
|
||||
beforeSend: (event) => {
|
||||
forwardEventToRemote(event);
|
||||
return event;
|
||||
},
|
||||
});
|
||||
const projectGroupId = process.env.EXPO_PUBLIC_PROJECT_GROUP_ID;
|
||||
if (projectGroupId) {
|
||||
Sentry.setTag("projectGroupId", projectGroupId);
|
||||
}
|
||||
} catch (_err) {
|
||||
// Silent — Sentry must never crash the host app
|
||||
}
|
||||
}
|
||||
428
apps/mobile/__create/testflight-logger.test.ts
Normal file
428
apps/mobile/__create/testflight-logger.test.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
let mockSendLogsToRemote: jest.Mock;
|
||||
let mockGetItem: jest.Mock;
|
||||
let mockSetItem: jest.Mock;
|
||||
let mockRemoveItem: jest.Mock;
|
||||
let mockFileStore: Record<string, string>;
|
||||
let capturedErrorHandler: ((error: Error, isFatal?: boolean) => void) | null;
|
||||
let originalErrorUtils: unknown;
|
||||
|
||||
const CRASH_FILE = "/doc/testflight_crash_logs.json";
|
||||
|
||||
const STORAGE_KEY = "testflight_logger_pending_logs";
|
||||
let originalDev: boolean;
|
||||
|
||||
function setDevMode(value: boolean) {
|
||||
(globalThis as Record<string, unknown>).__DEV__ = value;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
originalDev = (globalThis as Record<string, unknown>).__DEV__ as boolean;
|
||||
jest.resetModules();
|
||||
jest.useFakeTimers();
|
||||
process.env.EXPO_PUBLIC_CREATE_ENV = "PRODUCTION";
|
||||
|
||||
mockSendLogsToRemote = jest.fn().mockResolvedValue({ success: true });
|
||||
mockGetItem = jest.fn().mockResolvedValue(null);
|
||||
mockSetItem = jest.fn().mockResolvedValue(undefined);
|
||||
mockRemoveItem = jest.fn().mockResolvedValue(undefined);
|
||||
mockFileStore = {};
|
||||
|
||||
capturedErrorHandler = null;
|
||||
originalErrorUtils = (globalThis as Record<string, unknown>).ErrorUtils;
|
||||
(globalThis as Record<string, unknown>).ErrorUtils = {
|
||||
getGlobalHandler: () => () => {},
|
||||
setGlobalHandler: (handler: (error: Error, isFatal?: boolean) => void) => {
|
||||
capturedErrorHandler = handler;
|
||||
},
|
||||
};
|
||||
|
||||
jest.doMock("./report-error-to-remote", () => ({
|
||||
sendLogsToRemote: mockSendLogsToRemote,
|
||||
}));
|
||||
|
||||
jest.doMock("@react-native-async-storage/async-storage", () => ({
|
||||
getItem: mockGetItem,
|
||||
setItem: mockSetItem,
|
||||
removeItem: mockRemoveItem,
|
||||
}));
|
||||
|
||||
jest.doMock("expo-file-system", () => {
|
||||
class MockFile {
|
||||
uri: string;
|
||||
constructor(directory: string, name: string) {
|
||||
this.uri = `${directory}/${name}`;
|
||||
}
|
||||
get exists() {
|
||||
return Object.prototype.hasOwnProperty.call(mockFileStore, this.uri);
|
||||
}
|
||||
create() {
|
||||
if (!(this.uri in mockFileStore)) mockFileStore[this.uri] = "";
|
||||
}
|
||||
delete() {
|
||||
delete mockFileStore[this.uri];
|
||||
}
|
||||
write(content: string) {
|
||||
mockFileStore[this.uri] = content;
|
||||
}
|
||||
textSync() {
|
||||
return mockFileStore[this.uri] ?? "";
|
||||
}
|
||||
}
|
||||
return { File: MockFile, Paths: { document: "/doc" } };
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
setDevMode(originalDev);
|
||||
delete process.env.EXPO_PUBLIC_CREATE_ENV;
|
||||
(globalThis as Record<string, unknown>).ErrorUtils = originalErrorUtils;
|
||||
});
|
||||
|
||||
function loadModule() {
|
||||
return require("./testflight-logger") as typeof import("./testflight-logger");
|
||||
}
|
||||
|
||||
describe("initTestFlightLogger", () => {
|
||||
it("is a no-op when __DEV__ is true", async () => {
|
||||
setDevMode(true);
|
||||
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
|
||||
expect(getTestFlightLogger()).toBeNull();
|
||||
expect(mockSendLogsToRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a no-op when EXPO_PUBLIC_CREATE_ENV is DEVELOPMENT", async () => {
|
||||
setDevMode(false);
|
||||
process.env.EXPO_PUBLIC_CREATE_ENV = "DEVELOPMENT";
|
||||
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
|
||||
expect(getTestFlightLogger()).toBeNull();
|
||||
});
|
||||
|
||||
it("activates when not in dev mode", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
const logger = getTestFlightLogger();
|
||||
expect(logger).not.toBeNull();
|
||||
expect(logger).toHaveProperty("logError");
|
||||
});
|
||||
|
||||
it("only creates one instance on multiple calls", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const first = getTestFlightLogger();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
const second = getTestFlightLogger();
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("console patching", () => {
|
||||
it("intercepts console.log and buffers the message", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
console.log("test message");
|
||||
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(mockSendLogsToRemote).toHaveBeenCalled();
|
||||
const logs = mockSendLogsToRemote.mock.calls[0][0];
|
||||
const logMessage = logs.find(
|
||||
(l: Record<string, string>) => l.message === "test message",
|
||||
);
|
||||
expect(logMessage).toBeDefined();
|
||||
expect(logMessage.level).toBe("log");
|
||||
expect(logMessage.source).toBe("TEST_FLIGHT");
|
||||
});
|
||||
|
||||
it("intercepts console.error", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
console.error("bad thing");
|
||||
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(mockSendLogsToRemote).toHaveBeenCalled();
|
||||
const logs = mockSendLogsToRemote.mock.calls[0][0];
|
||||
const errorLog = logs.find(
|
||||
(l: Record<string, string>) => l.message === "bad thing",
|
||||
);
|
||||
expect(errorLog).toBeDefined();
|
||||
expect(errorLog.level).toBe("error");
|
||||
});
|
||||
|
||||
it("serializes non-string arguments as JSON", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
console.log("count:", { x: 1 });
|
||||
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
const logs = mockSendLogsToRemote.mock.calls[0][0];
|
||||
const entry = logs.find((l: Record<string, string>) =>
|
||||
l.message.includes("count:"),
|
||||
);
|
||||
expect(entry.message).toBe('count: {"x":1}');
|
||||
});
|
||||
});
|
||||
|
||||
describe("logError", () => {
|
||||
it("immediately flushes error entries", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger, getTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
const logger = getTestFlightLogger()!;
|
||||
logger.logError("critical failure");
|
||||
|
||||
// Should flush without waiting for the 5s interval
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockSendLogsToRemote).toHaveBeenCalled();
|
||||
const logs = mockSendLogsToRemote.mock.calls[0][0];
|
||||
expect(logs[0].message).toBe("critical failure");
|
||||
expect(logs[0].level).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buffering and flushing", () => {
|
||||
it("flushes buffer every 5 seconds", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
console.log("entry 1");
|
||||
|
||||
// Not flushed yet at 3 seconds
|
||||
await jest.advanceTimersByTimeAsync(3_000);
|
||||
expect(mockSendLogsToRemote).not.toHaveBeenCalled();
|
||||
|
||||
// Flushed at 5 seconds
|
||||
await jest.advanceTimersByTimeAsync(2_000);
|
||||
expect(mockSendLogsToRemote).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("auto-flushes when buffer reaches 50 entries", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
console.log(`entry ${i}`);
|
||||
}
|
||||
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(mockSendLogsToRemote).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistence and retry", () => {
|
||||
it("persists logs to AsyncStorage when flush fails", async () => {
|
||||
mockSendLogsToRemote.mockResolvedValue({ success: false });
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
console.log("will fail");
|
||||
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(mockSetItem).toHaveBeenCalledWith(
|
||||
STORAGE_KEY,
|
||||
expect.stringContaining("will fail"),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores persisted logs on startup and re-sends them", async () => {
|
||||
const persistedLogs = [
|
||||
{
|
||||
message: "old log",
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
level: "error",
|
||||
source: "TEST_FLIGHT",
|
||||
sessionId: "old-session",
|
||||
},
|
||||
];
|
||||
mockGetItem.mockResolvedValueOnce(JSON.stringify(persistedLogs));
|
||||
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockRemoveItem).toHaveBeenCalledWith(STORAGE_KEY);
|
||||
expect(mockSendLogsToRemote).toHaveBeenCalledWith(persistedLogs);
|
||||
});
|
||||
|
||||
it("re-persists restored logs if resend also fails", async () => {
|
||||
const persistedLogs = [
|
||||
{
|
||||
message: "stubborn log",
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
level: "error",
|
||||
source: "TEST_FLIGHT",
|
||||
sessionId: "old-session",
|
||||
},
|
||||
];
|
||||
mockGetItem
|
||||
.mockResolvedValueOnce(JSON.stringify(persistedLogs))
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
mockSendLogsToRemote.mockResolvedValueOnce({ success: false });
|
||||
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockSetItem).toHaveBeenCalledWith(
|
||||
STORAGE_KEY,
|
||||
expect.stringContaining("stubborn log"),
|
||||
);
|
||||
});
|
||||
|
||||
it("caps persisted entries at 200", async () => {
|
||||
const existingLogs = Array.from({ length: 195 }, (_, i) => ({
|
||||
message: `existing ${i}`,
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
level: "log",
|
||||
source: "TEST_FLIGHT",
|
||||
sessionId: "s",
|
||||
}));
|
||||
|
||||
mockSendLogsToRemote.mockResolvedValue({ success: false });
|
||||
mockGetItem
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(JSON.stringify(existingLogs));
|
||||
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
console.log(`new ${i}`);
|
||||
}
|
||||
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
const setItemCalls = mockSetItem.mock.calls;
|
||||
const lastCall = setItemCalls[setItemCalls.length - 1];
|
||||
const saved = JSON.parse(lastCall[1]);
|
||||
expect(saved.length).toBeLessThanOrEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("crash persistence", () => {
|
||||
it("synchronously snapshots the buffer to the crash file on a fatal error", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
console.log("breadcrumb before crash");
|
||||
capturedErrorHandler!(new Error("startup boom"), true);
|
||||
|
||||
const crashFile = mockFileStore[CRASH_FILE];
|
||||
expect(crashFile).toContain("startup boom");
|
||||
expect(crashFile).toContain("breadcrumb before crash");
|
||||
});
|
||||
|
||||
it("does not snapshot the crash file for a non-fatal error", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
capturedErrorHandler!(new Error("recoverable"), false);
|
||||
|
||||
expect(mockFileStore[CRASH_FILE]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("captures the in-flight batch when a fatal error triggers auto-flush", async () => {
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
// One short of the auto-flush threshold; the fatal error is the 50th
|
||||
// entry, so addEntry's auto-flush empties `buffer` before
|
||||
// persistBufferSync runs.
|
||||
for (let i = 0; i < 49; i++) {
|
||||
console.log(`entry ${i}`);
|
||||
}
|
||||
capturedErrorHandler!(new Error("boundary crash"), true);
|
||||
|
||||
const crashFile = mockFileStore[CRASH_FILE];
|
||||
expect(crashFile).toContain("boundary crash");
|
||||
expect(crashFile).toContain("entry 0");
|
||||
});
|
||||
|
||||
it("ships crash-file logs on the next startup and clears the file", async () => {
|
||||
mockFileStore[CRASH_FILE] = JSON.stringify([
|
||||
{
|
||||
message: "[FATAL] crashed last run",
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
level: "error",
|
||||
source: "TEST_FLIGHT",
|
||||
sessionId: "prev",
|
||||
},
|
||||
]);
|
||||
setDevMode(false);
|
||||
const { initTestFlightLogger } = loadModule();
|
||||
|
||||
initTestFlightLogger();
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(mockSendLogsToRemote).toHaveBeenCalled();
|
||||
const sent = mockSendLogsToRemote.mock.calls[0][0];
|
||||
expect(
|
||||
sent.some(
|
||||
(entry: Record<string, string>) =>
|
||||
entry.message === "[FATAL] crashed last run",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(mockFileStore[CRASH_FILE]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
292
apps/mobile/__create/testflight-logger.ts
Normal file
292
apps/mobile/__create/testflight-logger.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { File, Paths } from "expo-file-system";
|
||||
import { AppState, type AppStateStatus } from "react-native";
|
||||
import { sendLogsToRemote } from "./report-error-to-remote";
|
||||
|
||||
const STORAGE_KEY = "testflight_logger_pending_logs";
|
||||
// Written synchronously from the crash handlers so logs survive a startup
|
||||
// crash that tears down the JS runtime before the async network / AsyncStorage
|
||||
// paths can finish. Shipped and cleared on the next launch.
|
||||
const CRASH_FILE_NAME = "testflight_crash_logs.json";
|
||||
const MAX_STORED_ENTRIES = 200;
|
||||
const MAX_BUFFER_SIZE = 50;
|
||||
const FLUSH_INTERVAL_MS = 5_000;
|
||||
|
||||
interface LogEntry {
|
||||
message: string;
|
||||
timestamp: string;
|
||||
level: "log" | "info" | "warn" | "error" | "debug";
|
||||
source: "TEST_FLIGHT";
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
function isActive(): boolean {
|
||||
return !__DEV__ && process.env.EXPO_PUBLIC_CREATE_ENV !== "DEVELOPMENT";
|
||||
}
|
||||
|
||||
function generateSessionId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
let instance: TestFlightLogger | null = null;
|
||||
|
||||
class TestFlightLogger {
|
||||
private buffer: LogEntry[] = [];
|
||||
// Entries spliced out of `buffer` by an in-flight `flush()` that hasn't
|
||||
// confirmed delivery yet. Tracked so a crash mid-flush can still snapshot
|
||||
// them — `buffer` alone would miss them.
|
||||
private inFlightBatch: LogEntry[] = [];
|
||||
private sessionId: string;
|
||||
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private originalConsole: Record<string, (...args: unknown[]) => void> = {};
|
||||
private isFlushing = false;
|
||||
|
||||
constructor() {
|
||||
this.sessionId = generateSessionId();
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
try {
|
||||
await this.restorePersistedLogs();
|
||||
this.patchConsole();
|
||||
this.hookUncaughtExceptions();
|
||||
this.hookUnhandledRejections();
|
||||
this.hookAppState();
|
||||
this.flushTimer = setInterval(() => {
|
||||
this.flush();
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
} catch (_err) {
|
||||
// Silent — the logger must never crash the host app
|
||||
}
|
||||
}
|
||||
|
||||
logError(message: string): void {
|
||||
try {
|
||||
this.addEntry("error", message);
|
||||
this.flush();
|
||||
} catch (_err) {
|
||||
// Silent
|
||||
}
|
||||
}
|
||||
|
||||
private addEntry(level: LogEntry["level"], message: string): void {
|
||||
this.buffer.push({
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
source: "TEST_FLIGHT",
|
||||
sessionId: this.sessionId,
|
||||
});
|
||||
if (this.buffer.length >= MAX_BUFFER_SIZE) {
|
||||
this.flush();
|
||||
}
|
||||
}
|
||||
|
||||
private patchConsole(): void {
|
||||
const levels = ["log", "info", "warn", "error", "debug"] as const;
|
||||
for (const level of levels) {
|
||||
this.originalConsole[level] = console[level].bind(console);
|
||||
console[level] = (...args: unknown[]) => {
|
||||
try {
|
||||
const message = args
|
||||
.map((arg) => {
|
||||
if (typeof arg === "string") return arg;
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
})
|
||||
.join(" ");
|
||||
this.addEntry(level, message);
|
||||
} catch (_err) {
|
||||
// Silent
|
||||
}
|
||||
this.originalConsole[level]?.(...args);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private hookUncaughtExceptions(): void {
|
||||
const ErrorUtils = (globalThis as Record<string, unknown>).ErrorUtils as
|
||||
| {
|
||||
getGlobalHandler: () => (error: Error, isFatal?: boolean) => void;
|
||||
setGlobalHandler: (
|
||||
handler: (error: Error, isFatal?: boolean) => void,
|
||||
) => void;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!ErrorUtils) return;
|
||||
|
||||
const previousHandler = ErrorUtils.getGlobalHandler();
|
||||
ErrorUtils.setGlobalHandler((error: Error, isFatal?: boolean) => {
|
||||
try {
|
||||
const tag = isFatal ? "[FATAL]" : "[UNCAUGHT]";
|
||||
this.addEntry("error", `${tag} ${error.message}\n${error.stack ?? ""}`);
|
||||
// Only a fatal error tears down the runtime before the async
|
||||
// flush can finish, so only then is the synchronous crash-file
|
||||
// snapshot needed. Non-fatal errors are delivered by flush /
|
||||
// AsyncStorage retry; snapshotting them would just duplicate
|
||||
// entries on the next launch.
|
||||
if (isFatal) {
|
||||
this.persistBufferSync();
|
||||
}
|
||||
this.flush();
|
||||
} catch (_err) {
|
||||
// Silent
|
||||
}
|
||||
previousHandler(error, isFatal);
|
||||
});
|
||||
}
|
||||
|
||||
private hookUnhandledRejections(): void {
|
||||
const previous: ((event: PromiseRejectionEvent) => void) | null =
|
||||
globalThis.onunhandledrejection;
|
||||
globalThis.onunhandledrejection = (event: PromiseRejectionEvent) => {
|
||||
try {
|
||||
const reason =
|
||||
event.reason instanceof Error
|
||||
? `${event.reason.message}\n${event.reason.stack ?? ""}`
|
||||
: String(event.reason);
|
||||
this.addEntry("error", `[UNHANDLED_REJECTION] ${reason}`);
|
||||
this.flush();
|
||||
} catch (_err) {
|
||||
// Silent
|
||||
}
|
||||
if (previous) {
|
||||
previous(event);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private hookAppState(): void {
|
||||
AppState.addEventListener("change", (state: AppStateStatus) => {
|
||||
try {
|
||||
this.addEntry("info", `[APP_STATE] ${state}`);
|
||||
if (state === "background" || state === "inactive") {
|
||||
this.flush();
|
||||
}
|
||||
} catch (_err) {
|
||||
// Silent
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async flush(): Promise<void> {
|
||||
if (this.isFlushing || this.buffer.length === 0) return;
|
||||
this.isFlushing = true;
|
||||
const batch = this.buffer.splice(0);
|
||||
this.inFlightBatch = batch;
|
||||
try {
|
||||
const result = await sendLogsToRemote(batch);
|
||||
if (!result.success) {
|
||||
await this.persistLogs(batch);
|
||||
}
|
||||
} catch (_err) {
|
||||
await this.persistLogs(batch);
|
||||
} finally {
|
||||
this.inFlightBatch = [];
|
||||
this.isFlushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async persistLogs(logs: LogEntry[]): Promise<void> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
const existing: LogEntry[] = raw ? JSON.parse(raw) : [];
|
||||
const merged = [...existing, ...logs].slice(-MAX_STORED_ENTRIES);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
|
||||
} catch (_err) {
|
||||
// Silent
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronously snapshot pending logs to disk. Called from the crash
|
||||
// handlers before the runtime is torn down, so a startup crash is still
|
||||
// recoverable on the next launch. Includes any in-flight flush batch,
|
||||
// since `addEntry`'s auto-flush may have already emptied `buffer`.
|
||||
private persistBufferSync(): void {
|
||||
try {
|
||||
const pending = [...this.inFlightBatch, ...this.buffer];
|
||||
if (pending.length === 0) return;
|
||||
const merged = [...this.readCrashLogsSync(), ...pending].slice(
|
||||
-MAX_STORED_ENTRIES,
|
||||
);
|
||||
const file = new File(Paths.document, CRASH_FILE_NAME);
|
||||
if (file.exists) {
|
||||
file.delete();
|
||||
}
|
||||
file.create();
|
||||
file.write(JSON.stringify(merged));
|
||||
} catch (_err) {
|
||||
// Silent — the logger must never crash the host app
|
||||
}
|
||||
}
|
||||
|
||||
private readCrashLogsSync(): LogEntry[] {
|
||||
try {
|
||||
const file = new File(Paths.document, CRASH_FILE_NAME);
|
||||
if (!file.exists) return [];
|
||||
const parsed: unknown = JSON.parse(file.textSync());
|
||||
return Array.isArray(parsed) ? (parsed as LogEntry[]) : [];
|
||||
} catch (_err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private clearCrashFileSync(): void {
|
||||
try {
|
||||
const file = new File(Paths.document, CRASH_FILE_NAME);
|
||||
if (file.exists) {
|
||||
file.delete();
|
||||
}
|
||||
} catch (_err) {
|
||||
// Silent
|
||||
}
|
||||
}
|
||||
|
||||
private async restorePersistedLogs(): Promise<void> {
|
||||
try {
|
||||
const crashLogs = this.readCrashLogsSync();
|
||||
if (crashLogs.length > 0) {
|
||||
this.clearCrashFileSync();
|
||||
}
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
const storedLogs: LogEntry[] = raw ? JSON.parse(raw) : [];
|
||||
const logs = [...crashLogs, ...storedLogs];
|
||||
if (logs.length === 0) return;
|
||||
const result = await sendLogsToRemote(logs);
|
||||
if (!result.success) {
|
||||
await this.persistLogs(logs);
|
||||
}
|
||||
} catch (_err) {
|
||||
// Silent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initTestFlightLogger(): void {
|
||||
try {
|
||||
if (!isActive()) return;
|
||||
if (instance) return;
|
||||
instance = new TestFlightLogger();
|
||||
instance.start();
|
||||
} catch (_err) {
|
||||
// Silent
|
||||
}
|
||||
}
|
||||
|
||||
export function getTestFlightLogger(): {
|
||||
logError: (message: string) => void;
|
||||
} | null {
|
||||
try {
|
||||
if (!isActive()) return null;
|
||||
return instance;
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user