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:
Bas van Rossem
2026-06-17 10:19:33 +02:00
commit d94d0b188b
192 changed files with 50705 additions and 0 deletions

View 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,
};

View 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,
},
]);
};

View 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");
});
});

View 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
}
}

View 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();
});
});

View 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;
}
}