Skip to content
Open
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
12 changes: 7 additions & 5 deletions apps/integration/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
{
// No `paths` alias for "@comapeo/core-react-native" → "../../src/*" — same
// reasoning as apps/e2e/tsconfig.json: Expo's Metro honours tsconfig `paths`,
// which both double-loads the module (src + build → two IPC clients on one
// socket) and fails the release bundle (Metro can't resolve src's
// ESM-style ".js" imports to .ts). Resolve via the node_modules symlink
// (→ build/) only; run `npm run build` to pick up module src changes.
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@comapeo/core-react-native": ["../../src/index"],
"@comapeo/core-react-native/*": ["../../src/*"]
}
"strict": true
}
}
83 changes: 83 additions & 0 deletions backend/lib/als-async-context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// `AsyncLocalStorage`-backed async-context strategy for `@sentry/core`.
//
// `@sentry/core` ships a synchronous stack strategy by default, which
// cannot carry the current/isolation scope across an `await`. Node SDKs
// therefore install a strategy of their own; `@sentry/node-core` used the
// OpenTelemetry context manager for this, which is the single reason the
// whole OTel stack had to be bundled. This is the same strategy without
// OTel. It mirrors `setAsyncLocalStorageAsyncContextStrategy` in
// `@sentry/server-utils` (MIT), which v11's `@sentry/node` installs unless
// `enableOpenTelemetrySetup` is set — replace this file with that import
// when the backend moves to SDK v11.
//
// Registered from `sentry-init.js`'s `init()` before the client is bound,
// so every `startSpan` / `withScope` / `continueTrace` in `sentry.js`
// keeps its context across the async boundaries in the RPC and boot paths.

import { AsyncLocalStorage } from "node:async_hooks";
import {
getDefaultCurrentScope,
getDefaultIsolationScope,
setAsyncContextStrategy,
} from "@sentry/core";

/** @typedef {import("@sentry/core").Scope} Scope */
/** @typedef {{ scope: Scope, isolationScope: Scope }} Scopes */

/**
* Install the strategy on the global Sentry carrier. Idempotent in
* effect — a second call simply replaces the strategy with an equivalent
* one over a fresh store, which matters only if `init` runs twice (the
* unit tests do exactly that).
*/
export function setAlsAsyncContextStrategy() {
/** @type {AsyncLocalStorage<Scopes>} */
const asyncStorage = new AsyncLocalStorage();

/** @returns {Scopes} */
function getScopes() {
return (
asyncStorage.getStore() ?? {
scope: getDefaultCurrentScope(),
isolationScope: getDefaultIsolationScope(),
}
);
}

/** @type {import("@sentry/core").AsyncContextStrategy} */
const strategy = {
withScope(callback) {
const { scope, isolationScope } = getScopes();
const newScope = scope.clone();
return asyncStorage.run({ scope: newScope, isolationScope }, () =>
callback(newScope),
);
},
withSetScope(scope, callback) {
const { isolationScope } = getScopes();
return asyncStorage.run({ scope, isolationScope }, () => callback(scope));
},
// Both isolation-scope methods fork the *current* scope alongside the
// isolation scope, so a current-scope mutation inside the callback
// cannot escape to the caller.
withIsolationScope(callback) {
const { scope, isolationScope } = getScopes();
const newScope = scope.clone();
const newIsolationScope = isolationScope.clone();
return asyncStorage.run(
{ scope: newScope, isolationScope: newIsolationScope },
() => callback(newIsolationScope),
);
},
withSetIsolationScope(isolationScope, callback) {
const newScope = getScopes().scope.clone();
return asyncStorage.run({ scope: newScope, isolationScope }, () =>
callback(isolationScope),
);
},
getCurrentScope: () => getScopes().scope,
getIsolationScope: () => getScopes().isolationScope,
};

setAsyncContextStrategy(strategy);
}
89 changes: 89 additions & 0 deletions backend/lib/als-async-context.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { test } from "node:test";
import assert from "node:assert/strict";

import {
getCurrentScope,
getIsolationScope,
withIsolationScope,
withScope,
} from "@sentry/core";

import { setAlsAsyncContextStrategy } from "./als-async-context.js";

/**
* The property that motivates the strategy at all: `@sentry/core`'s
* default (synchronous stack) strategy cannot keep a forked scope
* attached across an `await`, so two concurrent tasks would see each
* other's scope data. Every span/scope in `sentry.js` — the RPC hook,
* `withBootTrace`, `withSpan` — straddles an await, so this is the
* behaviour the whole file exists to provide.
*/

const tick = () => new Promise((resolve) => setTimeout(resolve, 0));

test("withScope isolates concurrent awaited tasks from each other", async () => {
setAlsAsyncContextStrategy();

/**
* Interleaves with its sibling: reads its own tag after the other has run.
* @param {string} name
*/
async function task(name) {
return withScope(async (scope) => {
scope.setTag("task", name);
await tick();
await tick();
return getCurrentScope().getScopeData().tags.task;
});
}

const [a, b] = await Promise.all([task("a"), task("b")]);
assert.equal(a, "a");
assert.equal(b, "b");
// The fork must not leak into the ambient scope either.
assert.equal(getCurrentScope().getScopeData().tags.task, undefined);
});

test("withIsolationScope forks independently across awaits", async () => {
setAlsAsyncContextStrategy();

/** @param {string} name */
async function task(name) {
return withIsolationScope(async (isolationScope) => {
isolationScope.setTag("iso", name);
await tick();
return getIsolationScope().getScopeData().tags.iso;
});
}

const [a, b] = await Promise.all([task("a"), task("b")]);
assert.equal(a, "a");
assert.equal(b, "b");
assert.equal(getIsolationScope().getScopeData().tags.iso, undefined);
});

test("withIsolationScope forks the current scope too", async () => {
setAlsAsyncContextStrategy();

await withIsolationScope(async () => {
getCurrentScope().setTag("inner", "yes");
await tick();
});

assert.equal(getCurrentScope().getScopeData().tags.inner, undefined);
});

test("a nested withScope inherits the enclosing scope's data", async () => {
setAlsAsyncContextStrategy();

const inner = await withScope(async (outerScope) => {
outerScope.setTag("outer", "yes");
await tick();
return withScope(async () => {
await tick();
return getCurrentScope().getScopeData().tags.outer;
});
});

assert.equal(inner, "yes");
});
10 changes: 6 additions & 4 deletions backend/lib/metrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
// emission carrying a forbidden attribute.
//
// Populated by `sentry.js`'s `init()`, which has the live SDK + the
// resolved device tags from argv. No static dep on `@sentry/node-core`
// so the chunk stays unloaded when Sentry is off.
// resolved device tags from argv. No static dep on `@sentry/core` so
// the chunk stays unloaded when Sentry is off.

import { isForbiddenMetric } from "../before-send.js";

/** @type {typeof import("@sentry/node-core") | null} */
/** @typedef {import("./sentry-init.js").SentrySdk} SentrySdk */

/** @type {SentrySdk | null} */
let Sentry = null;
/**
* @type {{
Expand All @@ -33,7 +35,7 @@ let config = null;

/**
* @param {{
* Sentry: typeof import("@sentry/node-core"),
* Sentry: SentrySdk,
* platform: string,
* deviceClass: string,
* osMajor: string,
Expand Down
Loading