Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/** @type {import('jest').Config} */
module.exports = {
// Source uses `.js` extensions on relative imports (required for the
// emitted ESM to resolve at runtime under `moduleResolution: nodenext`),
// but the files on disk are `.ts` and `tsc` rewrites nothing. Jest's
// resolver doesn't apply TypeScript's `.js`→`.ts` remap, so strip the
// extension back off before it resolves.
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
},
// All Jest tests live under src/. Scoping roots here (rather than the
// default rootDir) keeps the test/haste scan out of backend/, ios/, and
// any transient .claude/ worktrees without naming machine-specific paths.
roots: ["<rootDir>/src"],
};
206 changes: 104 additions & 102 deletions src/ComapeoCoreModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type MessageEventPayload,
type NotificationPermissionResponse,
type StateChangeEventPayload,
} from "./ComapeoCore.types";
} from "./ComapeoCore.types.js";
import type { MessagePortLike } from "rpc-reflector";
import {
createComapeoCoreClient,
Expand All @@ -21,8 +21,8 @@ import * as Sentry from "@sentry/react-native";
// `@sentry/react-native@7`; `@sentry/core` is a direct dep of RN so
// the import is safe.
import { getTraceData, startNewTrace } from "@sentry/core";
import type { SentryInitConfig } from "./sentry";
import { rpcClientMetric, rpcStatusFor } from "./sentry-metrics";
import type { SentryInitConfig } from "./sentry.js";
import { rpcClientMetric, rpcStatusFor } from "./sentry-metrics.js";

// `onRequestHook` request type derived from `createComapeoCoreClient` so
// any hook-signature change up-stream is a compile error here. The
Expand Down Expand Up @@ -347,109 +347,111 @@ const debugTracingEnabled = (() => {
return prefs.diagnosticsEnabled && prefs.debug;
})();

export const comapeo: ComapeoCoreClientApi = createComapeoCoreClient(messagePort, {
timeout: RPC_TIMEOUT_MS,
onRequestHook: (request, next) => {
// Sentry-not-initialised guard. `isInitialized` lives in `@sentry/core`
// and is reachable through the namespace at runtime but isn't on the
// public type surface — defensive accessor in case the helper isn't
// wired through in older SDK releases.
const isInitialized = (
Sentry as unknown as {
isInitialized?: () => boolean;
export const comapeo: ComapeoCoreClientApi = createComapeoCoreClient(
messagePort,
{
timeout: RPC_TIMEOUT_MS,
onRequestHook: (request, next) => {
// Sentry-not-initialised guard. `isInitialized` lives in `@sentry/core`
// and is reachable through the namespace at runtime but isn't on the
// public type surface — defensive accessor in case the helper isn't
// wired through in older SDK releases.
const isInitialized = (
Sentry as unknown as {
isInitialized?: () => boolean;
}
).isInitialized;
const sentryUp = typeof isInitialized !== "function" || isInitialized();
const method = request.method.join(".");

// Metrics/tracing only — the hook never captures exceptions. An RPC
// rejection is often expected control flow (e.g. NotFound) that the
// caller may not want reported; deciding what's report-worthy is the
// caller's job, at the call site. The metric layer no-ops when Sentry is
// off. Per-RPC traces (below) only run under `debug`.
const recordMetric = (start: number, status: string) => {
rpcClientMetric(method, status, performance.now() - start);
};

if (!sentryUp || !debugTracingEnabled) {
const start = performance.now();
const responsePromise = next(request);
responsePromise
.then(
() => recordMetric(start, "ok"),
(error: unknown) => recordMetric(start, rpcStatusFor(error)),
)
.catch(noop);
return;
}
).isInitialized;
const sentryUp =
typeof isInitialized !== "function" || isInitialized();
const method = request.method.join(".");

// Metrics/tracing only — the hook never captures exceptions. An RPC
// rejection is often expected control flow (e.g. NotFound) that the
// caller may not want reported; deciding what's report-worthy is the
// caller's job, at the call site. The metric layer no-ops when Sentry is
// off. Per-RPC traces (below) only run under `debug`.
const recordMetric = (start: number, status: string) => {
rpcClientMetric(method, status, performance.now() - start);
};

if (!sentryUp || !debugTracingEnabled) {
const start = performance.now();
const responsePromise = next(request);
responsePromise
.then(
() => recordMetric(start, "ok"),
(error: unknown) => recordMetric(start, rpcStatusFor(error)),
)
.catch(noop);
return;
}

const runSpan = () =>
Sentry.startSpan(
{
name: method,
op: "rpc.client",
forceTransaction: true,
attributes: {
"rpc.system": "comapeo-ipc",
"rpc.method": method,
const runSpan = () =>
Sentry.startSpan(
{
name: method,
op: "rpc.client",
forceTransaction: true,
attributes: {
"rpc.system": "comapeo-ipc",
"rpc.method": method,
},
},
},
async (span) => {
const { "sentry-trace": sentryTrace, baggage } = getTraceData({
span,
});
const tracedRequest: IpcRequestWithMetadata = sentryTrace
? {
...request,
metadata: {
"sentry-trace": sentryTrace,
baggage: baggage ?? "",
},
}
: request;
// Record the metric while the span is active so it links to the
// trace. Duration is measured around the same round-trip the
// span brackets.
const start = performance.now();
try {
// Split the span duration into "sync send" (JSI hop + UDS write
// to Node) and "await" (entire round-trip incl. response delivery
// back to the JS thread). If the gap between this span and the
// Node-side rpc span is dominated by JS-thread contention on
// cold boot, `rn.send.syncMs` stays small while total stays high.
const sendStart = performance.now();
const responsePromise = next(tracedRequest);
const sendMs = performance.now() - sendStart;
span.setAttribute?.("rn.send.syncMs", sendMs);
await responsePromise;
span.setStatus?.({ code: 1, message: "ok" });
recordMetric(start, "ok");
} catch (error) {
// Mark the span errored for tracing, but do not capture an issue
// — see the metrics-only note on the non-debug path above.
span.setStatus?.({ code: 2, message: "internal_error" });
recordMetric(start, rpcStatusFor(error));
}
},
);
// Mint a fresh trace_id when there's no caller context worth
// inheriting. Without `startNewTrace`, every standalone RPC pulls
// the trace_id from the isolation-scope's propagation context,
// which is set once at SDK init and never rotates — so unrelated
// RPC calls (across reloads, even across days) end up sharing
// one trace. Skip `app.start.*` parents specifically: the
// `appStartIntegration` keeps its transaction open for ~10s
// post-launch, which would otherwise sweep any RPC fired during
// that window into the App Start trace and make the dashboard
// render them as nested under it.
if (hasInheritableActiveSpan()) {
runSpan();
} else {
startNewTrace(runSpan);
}
async (span) => {
const { "sentry-trace": sentryTrace, baggage } = getTraceData({
span,
});
const tracedRequest: IpcRequestWithMetadata = sentryTrace
? {
...request,
metadata: {
"sentry-trace": sentryTrace,
baggage: baggage ?? "",
},
}
: request;
// Record the metric while the span is active so it links to the
// trace. Duration is measured around the same round-trip the
// span brackets.
const start = performance.now();
try {
// Split the span duration into "sync send" (JSI hop + UDS write
// to Node) and "await" (entire round-trip incl. response delivery
// back to the JS thread). If the gap between this span and the
// Node-side rpc span is dominated by JS-thread contention on
// cold boot, `rn.send.syncMs` stays small while total stays high.
const sendStart = performance.now();
const responsePromise = next(tracedRequest);
const sendMs = performance.now() - sendStart;
span.setAttribute?.("rn.send.syncMs", sendMs);
await responsePromise;
span.setStatus?.({ code: 1, message: "ok" });
recordMetric(start, "ok");
} catch (error) {
// Mark the span errored for tracing, but do not capture an issue
// — see the metrics-only note on the non-debug path above.
span.setStatus?.({ code: 2, message: "internal_error" });
recordMetric(start, rpcStatusFor(error));
}
},
);
// Mint a fresh trace_id when there's no caller context worth
// inheriting. Without `startNewTrace`, every standalone RPC pulls
// the trace_id from the isolation-scope's propagation context,
// which is set once at SDK init and never rotates — so unrelated
// RPC calls (across reloads, even across days) end up sharing
// one trace. Skip `app.start.*` parents specifically: the
// `appStartIntegration` keeps its transaction open for ~10s
// post-launch, which would otherwise sweep any RPC fired during
// that window into the App Start trace and make the dashboard
// render them as nested under it.
if (hasInheritableActiveSpan()) {
runSpan();
} else {
startNewTrace(runSpan);
}
},
},
});
);

type StateEvents = {
stateChange: (state: ComapeoState, error: ComapeoErrorInfo | null) => void;
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ export {
comapeoServicesClient,
getNotificationPermissionsAsync,
requestNotificationPermissionsAsync,
} from "./ComapeoCoreModule";
export * from "./ComapeoCore.types";
} from "./ComapeoCoreModule.js";
export * from "./ComapeoCore.types.js";
15 changes: 8 additions & 7 deletions src/sentry-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ import * as Sentry from "@sentry/react-native";
import {
readSentryConfig,
readSentryPreferencesAtLaunch,
} from "./ComapeoCoreModule";
import type { SentryDeviceTags } from "./sentry";
import { isForbiddenMetric } from "./sentry-scrub";
} from "./ComapeoCoreModule.js";
import type { SentryDeviceTags } from "./sentry.js";
import { isForbiddenMetric } from "./sentry-scrub.js";

type MetricAttributes = Record<string, string | number | boolean>;

Expand Down Expand Up @@ -55,9 +55,8 @@ function deviceTags(): { device_class: string; os_major: string } {
}

function sentryUp(): boolean {
const isInitialized = (
Sentry as unknown as { isInitialized?: () => boolean }
).isInitialized;
const isInitialized = (Sentry as unknown as { isInitialized?: () => boolean })
.isInitialized;
return typeof isInitialized !== "function" || isInitialized();
}

Expand Down Expand Up @@ -127,7 +126,9 @@ function gauge(
*/
export function rpcStatusFor(error: unknown): string {
const name =
error instanceof Error ? error.name : String((error as { name?: string })?.name);
error instanceof Error
? error.name
: String((error as { name?: string })?.name);
if (/timeout/i.test(name)) return "timeout";
return "error";
}
Expand Down
22 changes: 10 additions & 12 deletions src/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,11 @@ import {
setApplicationUsageDataNative,
setDebugEnabledNative,
type SentryPreferences,
} from "./ComapeoCoreModule";
import type { ComapeoErrorInfo, ComapeoState } from "./ComapeoCore.types";
import { SentryTags, SENTRY_OWNED_GLOBAL_KEY } from "./sentry-tags";
import { scrubEvent, scrubBreadcrumb, scrubLog } from "./sentry-scrub";
import {
BACKEND_MODULES,
COMAPEO_MODULE_VERSION_LABEL,
} from "./version";
} from "./ComapeoCoreModule.js";
import type { ComapeoErrorInfo, ComapeoState } from "./ComapeoCore.types.js";
import { SentryTags, SENTRY_OWNED_GLOBAL_KEY } from "./sentry-tags.js";
import { scrubEvent, scrubBreadcrumb, scrubLog } from "./sentry-scrub.js";
import { BACKEND_MODULES, COMAPEO_MODULE_VERSION_LABEL } from "./version.js";

/**
* Subset of `Sentry.init` options that map cleanly from values the
Expand Down Expand Up @@ -287,9 +284,11 @@ export function initSentry(options: InitSentryOptions = {}): void {
// surface (it lives in `@sentry/core`'s utilities and is exposed
// through the namespace at runtime). Defensive accessor handles
// older SDK releases where the helper isn't wired through.
const maybeIsInitialized = (Sentry as unknown as {
isInitialized?: () => boolean;
}).isInitialized;
const maybeIsInitialized = (
Sentry as unknown as {
isInitialized?: () => boolean;
}
).isInitialized;
const sdkInitialized =
typeof maybeIsInitialized === "function" && maybeIsInitialized();

Expand Down Expand Up @@ -555,4 +554,3 @@ function handleAppStateChange(next: AppStateStatus): void {
}

AppState.addEventListener("change", handleAppStateChange);

4 changes: 3 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"extends": "expo-module-scripts/tsconfig.base",
"compilerOptions": {
"outDir": "./build"
"outDir": "./build",
"module": "nodenext",
"moduleResolution": "nodenext"
},
"include": ["./src"],
"exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
Expand Down