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
3 changes: 3 additions & 0 deletions backend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { createMapServer } from "./lib/create-map-server.js";
import { SimpleRpcServer } from "./lib/simple-rpc.js";
import * as sentry from "./lib/sentry.js";
import * as metrics from "./lib/metrics.js";
import { observeSyncSessions } from "./lib/sync-observer.js";

// 60s sampler cadence for backend memory + uptime gauges. No-op
// when Sentry is off (the metrics layer never got its SDK).
Expand Down Expand Up @@ -260,6 +261,8 @@ async function withPhase(phase, fn) {
rootKey,
});

observeSyncSessions(comapeoManager);

// Start the MapeoManager's Fastify so its blob/icon server is
// reachable. `$blobs.getUrl()` / `$icons.getUrl()` await the server's
// address (5s timeout, else AbortError). Non-fatal: surface listen
Expand Down
2 changes: 2 additions & 0 deletions backend/lib/metrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,8 @@ export function telemetryForwardingFailure() {

/** @param {number} peers @returns {string} */
export function peersBucket(peers) {
// A session can end without any peer ever connecting.
if (peers <= 0) return "0";
if (peers <= 3) return "1-3";
if (peers <= 10) return "4-10";
return "10+";
Expand Down
1 change: 1 addition & 0 deletions backend/lib/metrics.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ test("before_metric_send drops forbidden tag VALUES (lat/lng shape)", () => {
});

test("bucketing helpers match the spec thresholds", () => {
assert.equal(metrics.peersBucket(0), "0");
assert.equal(metrics.peersBucket(1), "1-3");
assert.equal(metrics.peersBucket(4), "4-10");
assert.equal(metrics.peersBucket(50), "10+");
Expand Down
80 changes: 73 additions & 7 deletions backend/lib/sentry.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ let Sentry = null;
/** @type {{ rpcArgsBytes: number, applicationUsageData: boolean, debug: boolean, deviceClass: string, osMajor: string, platformTag: string } | null} */
let config = null;

/** Root span name for the usage-tier sync-session transaction. */
export const SYNC_SESSION_TRANSACTION = "comapeo.sync.session";

/** Read-only view of the resolved config for the metrics layer. */
export function getConfig() {
return config;
Expand Down Expand Up @@ -141,18 +144,28 @@ export function init({ Sentry: sdk, argv, envelopeToFrame: toFrame, storageDir }
};
envelopeToFrame = toFrame;

// Native (Kotlin/Swift) owns the trace-sampling decision and forwards the
// already-resolved rate — it folds in the `debug` window (full sampling
// while on) and any plugin-configured cap. Mirror it verbatim here; absent
// means 0. Day-to-day perf signal rides the always-on metrics layer.
const baseTracesSampleRate = argv.sentryTracesSampleRate
? numericArg(argv.sentryTracesSampleRate)
: 0;

Sentry.init({
dsn: argv.sentryDsn,
environment: argv.sentryEnvironment,
release: argv.sentryRelease,
sampleRate: numericArg(argv.sentrySampleRate),
// Native (Kotlin/Swift) owns the trace-sampling decision and forwards the
// already-resolved rate — it folds in the `debug` window (full sampling
// while on) and any plugin-configured cap. Mirror it verbatim here; absent
// means 0. Day-to-day perf signal rides the always-on metrics layer.
tracesSampleRate: argv.sentryTracesSampleRate
? numericArg(argv.sentryTracesSampleRate)
: 0,
// The sync-session transaction is usage-tier, not debug-tier, so it must
// sample even while the base rate is 0. Everything else keeps the
// parent-based / base-rate behaviour a plain `tracesSampleRate` gives.
tracesSampler: ({ name, inheritOrSampleWith }) => {
if (name === SYNC_SESSION_TRANSACTION) {
return config?.applicationUsageData ? 1 : 0;
}
return inheritOrSampleWith(baseTracesSampleRate);
},
// v9 moved this out of `_experiments` — keep the CLI flag name so
// native doesn't have to change.
enableLogs: argv.sentryEnableLogs,
Expand Down Expand Up @@ -320,6 +333,59 @@ export async function withSpan(op, fn) {
});
}

/**
* @typedef {{
* startPhase: (phase: "discover" | "replicate") => void,
* end: (args: { outcome: string, peersBucket: string, bytesBucket: string }) => void,
* }} SyncSessionTransaction
*/

/**
* Start the usage-tier `comapeo.sync.session` transaction. Returns
* `null` when Sentry is off or `applicationUsageData` is off, so the
* caller treats the handle as optional. `end` sets the only attributes
* the transaction may carry — bucketed peer count, bucketed bytes,
* outcome (§11.3) — never peer identities or project IDs. `startPhase`
* ends the previous phase child span and starts the next.
*
* @returns {SyncSessionTransaction | null}
*/
export function startSyncSessionTransaction() {
if (!Sentry || !config?.applicationUsageData) return null;
const sentryRef = Sentry;
// Inactive span: a sync session is long-lived and concurrent with
// unrelated work, so it must not occupy the ALS context.
const root = sentryRef.startInactiveSpan({
name: SYNC_SESSION_TRANSACTION,
op: SYNC_SESSION_TRANSACTION,
forceTransaction: true,
});
if (!root) return null;
/** @type {ReturnType<typeof sentryRef.startInactiveSpan> | null} */
let phaseSpan = null;
return {
startPhase(phase) {
phaseSpan?.end();
phaseSpan = sentryRef.startInactiveSpan({
name: `sync.${phase}`,
op: `sync.${phase}`,
parentSpan: root,
});
},
end({ outcome, peersBucket, bytesBucket }) {
phaseSpan?.end();
phaseSpan = null;
root.setAttributes({
outcome,
peers_bucket: peersBucket,
bytes_bucket: bytesBucket,
});
root.setStatus({ code: 1, message: "ok" });
root.end();
},
};
}

/**
* Capture a fatal with phase + source tags. try/catch because
* `handleFatal` must still broadcast + exit even if Sentry throws.
Expand Down
151 changes: 151 additions & 0 deletions backend/lib/sync-observer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Sync-session telemetry derived from the lifecycle signals
// `@comapeo/core` exposes: each project's `$sync` emits `sync-state`
// with `data.isSyncEnabled` (edges mark session start/end) and a
// per-peer map of remaining `want`/`wanted` block counts. One session =
// the interval where data sync is enabled. Emits the usage-tier
// `comapeo.sync.session` transaction (see `sentry.js`) and the
// `metrics.syncSession` duration/bucket metrics — bucketed counts only,
// never peer identities or project IDs.

import * as metrics from "./metrics.js";
import * as sentry from "./sentry.js";

/**
* Public sync state shape from `@comapeo/core`'s SyncApi (`$sync`).
*
* @typedef {{
* isSyncEnabled: boolean,
* want: number,
* wanted: number,
* }} RemoteGroupState
* @typedef {{
* initial: { isSyncEnabled: boolean },
* data: { isSyncEnabled: boolean },
* remoteDeviceSyncState: Record<string, { initial: RemoteGroupState, data: RemoteGroupState }>,
* }} SyncApiState
* @typedef {{
* getState: () => SyncApiState,
* on: (event: "sync-state", listener: (state: SyncApiState) => void) => unknown,
* off: (event: "sync-state", listener: (state: SyncApiState) => void) => unknown,
* }} SyncApiLike
*/

// The sync API exposes remaining block counts, not byte counters, so a
// real bytes bucket isn't derivable; keep the attribute present with a
// stable placeholder until core exposes bytes.
const BYTES_BUCKET_UNKNOWN = "unknown";

/**
* Observe every project the RN client opens: wraps `manager.getProject`
* (the only project-open signal MapeoManager exposes — there is no
* project-opened event) and attaches a session watcher to each new
* project instance's `$sync`. No-op when Sentry never initialised.
*
* @param {import("@comapeo/core").MapeoManager} manager
*/
export function observeSyncSessions(manager) {
if (!metrics.isEnabled()) return;
/** @type {WeakSet<object>} */
const observed = new WeakSet();
const getProject = manager.getProject.bind(manager);
manager.getProject = async (projectPublicId) => {
const project = await getProject(projectPublicId);
if (!observed.has(project.$sync)) {
observed.add(project.$sync);
watchSyncApi(project.$sync);
}
return project;
};
}

/**
* Track sync sessions on one project's `$sync`. Session start is the
* `data.isSyncEnabled` false→true edge; end is the true→false edge
* (manual stop or autostop-after-synced). Phases: `sync.discover` while
* no peer is connected, `sync.replicate` once one is. A handshake phase
* is not derivable — the noise/protomux handshake is internal to core.
* Returns a detach function (used by tests).
*
* @param {SyncApiLike} syncApi
*/
export function watchSyncApi(syncApi) {
/**
* @type {{
* startedAt: number,
* maxPeers: number,
* synced: boolean,
* discovering: boolean,
* trace: import("./sentry.js").SyncSessionTransaction | null,
* } | null}
*/
let session = null;

/** @param {SyncApiState} state */
const onSyncState = (state) => {
const peers = Object.keys(state.remoteDeviceSyncState).length;
if (!session) {
if (!state.data.isSyncEnabled) return;
session = {
startedAt: performance.now(),
maxPeers: 0,
synced: false,
discovering: peers === 0,
trace: sentry.startSyncSessionTransaction(),
};
session.trace?.startPhase(session.discovering ? "discover" : "replicate");
}
if (peers > 0) {
session.maxPeers = Math.max(session.maxPeers, peers);
// Recomputed per event so late-arriving data flips it back off; kept
// when peers drop to 0 (an empty peer map says nothing about sync).
session.synced = isAllSynced(state);
if (session.discovering) {
session.discovering = false;
session.trace?.startPhase("replicate");
}
}
if (!state.data.isSyncEnabled) {
const outcome = session.synced ? "completed" : "stopped";
const durationMs = performance.now() - session.startedAt;
const peersBucket = metrics.peersBucket(session.maxPeers);
session.trace?.end({
outcome,
peersBucket,
bytesBucket: BYTES_BUCKET_UNKNOWN,
});
metrics.syncSession(
outcome,
durationMs,
peersBucket,
BYTES_BUCKET_UNKNOWN,
);
session = null;
}
};

syncApi.on("sync-state", onSyncState);
// Seed from the current state in case data sync was already running
// when the watcher attached.
onSyncState(syncApi.getState());
return () => {
syncApi.off("sync-state", onSyncState);
};
}

/**
* All connected peers have nothing left to send or receive in either
* namespace group. Only meaningful with ≥1 peer.
*
* @param {SyncApiState} state
*/
function isAllSynced(state) {
const devices = Object.values(state.remoteDeviceSyncState);
if (devices.length === 0) return false;
return devices.every(
(d) =>
d.initial.want === 0 &&
d.initial.wanted === 0 &&
d.data.want === 0 &&
d.data.wanted === 0,
);
}
Loading