Release, update, and observability: three safety boundaries
T3 Code's release graph publishes an exact CLI runtime before clients can require it. Desktop, service, and mobile each cross an update boundary differently, while analytics and diagnostics distinguish opt-out product telemetry from local traces, optional OTLP export, authenticated browser ingestion, and demand-driven native resource history.
What this chapter resolves
- Follow stable and nightly release-channel resolution through the exact-version publication invariant.
- Compare the desktop, boot-service, and Expo update boundaries without treating them as one protocol.
- Locate the point at which a server update becomes reversible, committed, and ready for client correlation.
- Separate anonymous product analytics, local traces, optional remote exporters, authenticated browser traces, and ephemeral resource diagnostics.
A release is not one artifact copied to three clients. It is a dependency graph
with a particular invariant: a client can offer to update a connected server to
its own version only after the exact t3@<version> runtime exists. The mechanisms
that consume that release then have different owners and different safe points:
Electron exits and installs an application, a boot-service launcher trials an
immutable server runtime around a SQLite snapshot, and Expo swaps a compatible
JavaScript bundle only after mobile persistence and lifecycle checks.
The same precision matters for diagnostics. Product analytics, server traces, browser traces, metrics, and native resource samples are related operational signals, but they do not share a destination, retention model, or authorization boundary.
Act I — release graph: publish the runtime before exposing the client
The release workflow resolves two channels. A scheduled or explicitly nightly run
uses the next patch version plus a UTC date and run number, publishes a GitHub
prerelease, and uses npm’s nightly dist-tag. A stable run requires a semver-like
version and produces a v<version> tag. The nightly lane is deliberately not the
latest stable release and has its own desktop updater channel.
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve_commit.outputs.ref }}
fetch-depth: 0
sparse-checkout: |
/*
!/.repos/
sparse-checkout-cone-mode: false
- name: Setup Vite+
uses: voidzero-dev/setup-vp@v1
with:
node-version-file: package.json
cache: true
run-install: true
env:
pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata
- id: release_meta
name: Resolve release version
shell: bash
env:
DISPATCH_CHANNEL: ${{ github.event.inputs.channel }}
DISPATCH_VERSION: ${{ github.event.inputs.version }}
NIGHTLY_VERSION: ${{ needs.resolve_commit.outputs.nightly_version }}
NIGHTLY_DATE: ${{ github.run_started_at }}
NIGHTLY_SHA: ${{ needs.resolve_commit.outputs.ref }}
NIGHTLY_RUN_NUMBER: ${{ github.run_number }}
run: |
if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then
nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)"
node scripts/resolve-nightly-release.ts \
--date "$nightly_date" \
--run-number "$NIGHTLY_RUN_NUMBER" \
--sha "$NIGHTLY_SHA" \
--github-output
echo "release_channel=nightly" >> "$GITHUB_OUTPUT"
echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT"
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
echo "make_latest=false" >> "$GITHUB_OUTPUT"
else
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
raw="${DISPATCH_VERSION:-$NIGHTLY_VERSION}"
if [[ -z "$raw" ]]; then
echo "workflow_dispatch stable releases need a version input or a published nightly." >&2
exit 1
fi
else
raw="${GITHUB_REF_NAME}"The important edge is downstream of naming. Connected servers are asked to install
the client’s exact version, not “whatever npm currently calls latest.” The
operations guide therefore requires publish_cli before release, and release
before the hosted web deployment. This prevents a newly visible client from issuing
an update request for a package version that cannot yet be fetched.
Zoom with the controls, +/−, or Ctrl/⌘ + trackpad scroll. Enable Pan to drag, use two-finger scrolling, or use the arrow keys. 0 fits the diagram; Esc leaves Pan or expanded view.
Text equivalent
A channel resolver feeds exact CLI publication. CLI publication gates the GitHub release. The GitHub release gates hosted-web deployment and supplies desktop updater metadata. A desktop or hosted client can request a connected boot-service server to install that exact npm version. A nightly channel is separate metadata and a prerelease, not a different server update algorithm.
deployment, so `app.t3.codes` points at a deployment containing the router
rules in `apps/web/vercel.ts`. Future stable releases keep this alias current.
## Nightly builds
- Workflow: `.github/workflows/release.yml`
- Triggers:
- scheduled check every 30 minutes
- manual `workflow_dispatch` with `channel=nightly`
- Automatic nightlies require new commits and at least six hours since the last nightly was published, including manual nightlies.
- Manual nightlies bypass the time and change checks. Nightly runs remain serialized. Scheduled runs wait for an active nightly to finish, then check the publication gap before building.
- Runs the same desktop quality gates and artifact matrix as the tagged release flow.
- Publishes a GitHub prerelease only:
- current tag format: `vX.Y.Z-nightly.YYYYMMDD.<run_number>`This is a release-time guarantee, not a claim that every machine is immediately updated. A disconnected server can remain on an earlier immutable runtime; a desktop client can decline to download; a mobile binary may be incompatible with an OTA bundle. The graph makes versions available in a safe order. The updater on each machine chooses whether and when to cross its own boundary.
Act II — three update architectures cross three different boundaries
1. Desktop: check automatically, but download and install by user action
The packaged desktop app configures an Electron updater for the selected channel.
It may check on startup and on an interval, but it does not silently download or
install. A check moves update state toward available; a user action starts the
download; a later user action starts quit-and-install. A single active action
reservation prevents overlapping check, download, channel-change, and install work.
const hasUpdateFeedConfig = Ref.get(appUpdateYmlConfigRef).pipe(
Effect.map((appUpdateYmlConfig) => Option.isSome(appUpdateYmlConfig) || config.mockUpdates),
);
const resolveDisabledReason = Effect.gen(function* () {
const hasFeedConfig = yield* hasUpdateFeedConfig;
return Option.fromNullishOr(
getAutoUpdateDisabledReason({
isDevelopment: environment.isDevelopment,
isPackaged: environment.isPackaged,
platform: environment.platform,
appImage: Option.getOrUndefined(config.appImagePath),
disabledByEnv: config.disableAutoUpdate,
hasUpdateFeedConfig: hasFeedConfig,
}),
);
});
const activeUpdateAction = Ref.get(activeUpdateActionRef);
const tryStartUpdateAction = (action: UpdateAction): Effect.Effect<boolean> =>
Ref.modify(activeUpdateActionRef, (activeAction) =>
Option.isSome(activeAction) ? [false, activeAction] : [true, Option.some(action)],
);
const tryStartChannelChange = Ref.modify(activeUpdateActionRef, (activeAction) =>
Option.isSome(activeAction)
? [activeAction, activeAction]
: [Option.none<UpdateAction>(), Option.some<UpdateAction>("channel")],
);
const finishUpdateAction = (action: UpdateAction): Effect.Effect<void> =>
Ref.modify(activeUpdateActionRef, (activeAction) => {
const finished = Option.isSome(activeAction) && activeAction.value === action;
return [finished, finished ? Option.none() : activeAction] as const;
}).pipe(
Effect.flatMap((finished) =>
finished ? PubSub.publish(finishedUpdateActions, action).pipe(Effect.asVoid) : Effect.void,
),
);
const applyAutoUpdaterChannel = Effect.fn("desktop.updates.applyAutoUpdaterChannel")(function* (
channel: DesktopUpdateChannel,
) {
yield* Effect.annotateCurrentSpan({ channel });
const allowsPrerelease = channel === "nightly";
yield* electronUpdater.setChannel(channel);
yield* electronUpdater.setAllowPrerelease(allowsPrerelease);
yield* electronUpdater.setAllowDowngrade(allowsPrerelease);
yield* electronUpdater.setFullChangelog(allowsPrerelease);
yield* logUpdaterInfo("using update channel", {
channel,
allowPrerelease: allowsPrerelease,
allowDowngrade: allowsPrerelease,
fullChangelog: allowsPrerelease,
});
});
const shouldEnableAutoUpdates = resolveDisabledReason.pipe(Effect.map(Option.isNone));
const checkForUpdates = Effect.fn("desktop.updates.checkForUpdates")(function* (
reason: string,
actionReservation: "acquire" | "held" = "acquire",
) {
yield* Effect.annotateCurrentSpan({ reason });
if (yield* Ref.get(desktopState.quitting)) return false;
if (!(yield* Ref.get(updaterConfiguredRef))) return false;
const state = yield* Ref.get(updateStateRef);
if (state.status === "downloading") {
yield* logUpdaterInfo("skipping update check while update is active", {
reason,
status: state.status,
});
return false;
}
if (actionReservation === "acquire" && !(yield* tryStartUpdateAction("check"))) return false;
const check = Effect.gen(function* () {
const checkedAt = yield* currentIsoTimestamp;
yield* setState(reduceDesktopUpdateStateOnCheckStart(state, checkedAt));
yield* logUpdaterInfo("checking for updates", { reason });
return yield* electronUpdater.checkForUpdates.pipe(
Effect.as(true),
Effect.catchTags({
ElectronUpdaterCheckForUpdatesError: Effect.fn(
"desktop.updates.handleCheckForUpdatesFailure",
)(function* (error) {
const failedAt = yield* currentIsoTimestamp;
yield* updateState((current) =>
reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt),
);
yield* logUpdaterError(error.message, {
errorTag: error._tag,
channel: error.channel,
});
return true;
}),
}),
);
});
return yield* actionReservation === "held"
? check
: check.pipe(
Effect.onInterrupt(() => setState(state)),
Effect.ensuring(finishUpdateAction("check")),
);
});
const downloadAvailableUpdate = Effect.gen(function* () {
const state = yield* Ref.get(updateStateRef);
if (!(yield* Ref.get(updaterConfiguredRef)) || state.status !== "available") {
return { accepted: false, completed: false };
}
if (!(yield* tryStartUpdateAction("download"))) {
return { accepted: false, completed: false };
}
return yield* Effect.gen(function* () {
yield* setState(reduceDesktopUpdateStateOnDownloadStart(state));
yield* electronUpdater.setDisableDifferentialDownload(Install is a process-lifecycle boundary, not merely a file replacement. Before
quitAndInstall, the app stops every backend in the pool—including parallel WSL
and Windows instances—with a grace budget, destroys windows, then yields control to
the updater. That avoids relying on an application shutdown cascade after the OS
has begun quitting it.
Updating a desktop-managed remote backend uses a separate prepare/commit token. Preparation returns while the connection is alive; the client commits only after it has received that token, then verifies the prepared version after reconnecting. If installation fails, desktop restarts the stopped backends and retains the failure for that same token.
Effect.catchTags({
ElectronUpdaterDownloadUpdateError: Effect.fn("desktop.updates.handleDownloadFailure")(
function* (error) {
yield* updateState((current) =>
reduceDesktopUpdateStateOnDownloadFailure(current, error.message),
);
yield* logUpdaterError(error.message, {
errorTag: error._tag,
channel: error.channel,
});
return { accepted: true, completed: false };
},
),
}),
Effect.onInterrupt(() =>
updateState((current) => (current.status === "downloading" ? state : current)).pipe(
Effect.asVoid,
),
),
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) {
return Effect.failCause(cause);
}
const error = new DesktopUpdateUnexpectedActionError({ action: "download", cause });
return Effect.gen(function* () {
yield* updateState((current) =>
reduceDesktopUpdateStateOnDownloadFailure(current, error.message),
);
yield* logUpdaterError(error.message, {
errorTag: error._tag,
action: error.action,
});
return { accepted: true, completed: false };
});
}),
Effect.ensuring(finishUpdateAction("download")),
);
}).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate"));
const resetInstallAction = Effect.all(
[finishUpdateAction("install"), Ref.set(desktopState.quitting, false)],
{ discard: true },
);
const recoverFailedInstall = Effect.fn("desktop.updates.recoverFailedInstall")(function* (
message: string,
) {
const ownsRecovery = yield* Ref.modify(activeUpdateActionRef, (activeAction) =>
Option.isSome(activeAction) && activeAction.value === "install"
? ([true, Option.some<UpdateAction>("install-recovery")] as const)
: ([false, activeAction] as const),
);
if (!ownsRecovery) return;
yield* Ref.set(desktopState.quitting, false);
yield* Effect.gen(function* () {
const instances = yield* pool.list;
const restartExit = yield* Effect.forEach(instances, (instance) => instance.start, {
concurrency: "unbounded",
discard: true,
}).pipe(Effect.exit);
yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message));
if (Exit.isFailure(restartExit)) {
yield* logUpdaterError("Desktop update install recovery could not restart every backend.");
}
}).pipe(
Effect.catchCause(() =>
logUpdaterError("Desktop update install recovery failed unexpectedly."),
),
Effect.ensuring(finishUpdateAction("install-recovery")),
);
});
const installDownloadedUpdate = (expectedVersion?: string) =>
Effect.scoped(
Effect.gen(function* () {
const actionCompletions = yield* PubSub.subscribe(finishedUpdateActions);
let admission: "admitted" | "refused" | "wait-for-check" = "wait-for-check";
while (admission === "wait-for-check") {
admission = yield* stateMutex.withPermits(1)(
Effect.gen(function* () {
const state = yield* Ref.get(updateStateRef);
const activeAction = yield* Ref.get(activeUpdateActionRef);
const hasExpectedDownload =
state.downloadedVersion !== null &&
(expectedVersion === undefined || state.downloadedVersion === expectedVersion);
if (
(yield* Ref.get(desktopState.quitting)) ||
!(yield* Ref.get(updaterConfiguredRef)) ||
!hasExpectedDownload
) {
return "refused" as const;
}
if (Option.isSome(activeAction)) {
return activeAction.value === "check" && expectedVersion !== undefined
? ("wait-for-check" as const)
: ("refused" as const);
}
const hasInstallableDownload =
state.status === "downloaded" ||
(state.status === "error" &&
(state.errorContext === null || state.errorContext === "install"));
if (!hasInstallableDownload) return "refused" as const;
return (yield* tryStartUpdateAction("install"))
? ("admitted" as const)
: ("refused" as const);
}),
);
if (admission === "wait-for-check") {
const finishedAction = yield* PubSub.take(actionCompletions).pipe(
Effect.timeoutOption(PREPARED_INSTALL_CHECK_WAIT),2. Boot-service server: stage, preflight, trial, then have the launcher commit
The remote server path is intentionally stricter because it can migrate the state that serves a remote client. It only exists for a server running under the supported background-service launcher; a desktop-managed server tells the user to update its desktop app, and an unsupported process shape gives manual-install guidance.
The active child first downloads an immutable exact-version runtime into staging and
runs its __service-preflight. The preflight result must name the requested version
and report readiness before the runtime is placed into its version directory. Only
then does the child ask the stable launcher to activate the prepared version.
readonly prepare: Effect.Effect<ReadonlyArray<ThreadId>, ServerSelfUpdateError>;
readonly clear: (
threadIds: ReadonlyArray<ThreadId>,
) => Effect.Effect<void, ServerSelfUpdateError>;
}) {
const desktopContinuationTokens = yield* Ref.make(HashSet.empty<string>());
const clearOnError = <A>(
effect: Effect.Effect<A, ServerSelfUpdateError>,
threadIds: () => ReadonlyArray<ThreadId>,
handoffAccepted: () => boolean,
): Effect.Effect<A, ServerSelfUpdateError> =>
effect.pipe(
Effect.catchCause((cause) =>
(handoffAccepted() && Cause.hasInterruptsOnly(cause)
? Effect.void
: input.clear(threadIds())
).pipe(Effect.andThen(Effect.failCause(cause))),
),
);
const update: ServerSelfUpdate["Service"]["update"] = (
request,
reportProgress = () => Effect.void,
) => {
let prepared = false;
let handoffAccepted = false;
let continuationThreadIds: ReadonlyArray<ThreadId> = [];
return clearOnError(
input.selfUpdate
.update(
request,
(stage) =>
(request.continueRunningThreads === true &&
input.mode !== "desktop" &&
stage === "installing" &&
!prepared
? input.prepare.pipe(
Effect.tap((threadIds) =>
Effect.sync(() => {
prepared = true;
continuationThreadIds = threadIds;
}),
),
Effect.asVoid,
)
: Effect.void
).pipe(Effect.andThen(reportProgress(stage))),
() =>
Effect.sync(() => {
handoffAccepted = true;
}),
)
.pipe(
Effect.tap((result) => {
if (
result.method === "desktop-app" &&
result.desktopUpdateToken !== undefined &&
request.continueRunningThreads === true
) {
return Ref.update(desktopContinuationTokens, HashSet.add(result.desktopUpdateToken));
}
return Effect.void;
}),
),
() => continuationThreadIds,
() => handoffAccepted,
);
};
return ServerSelfUpdate.of({
update,
commitDesktopUpdate: (requestId) =>
Effect.gen(function* () {
const shouldContinue = yield* Ref.modify(desktopContinuationTokens, (tokens) => [
HashSet.has(tokens, requestId),
HashSet.remove(tokens, requestId),
]);
let handoffAccepted = false;
let continuationThreadIds: ReadonlyArray<ThreadId> = [];
return yield* clearOnError(
Effect.gen(function* () {
continuationThreadIds = shouldContinue ? yield* input.prepare : [];
return yield* input.selfUpdate.commitDesktopUpdate(requestId, () =>
Effect.sync(() => {
handoffAccepted = true;
}),
);
}),
() => continuationThreadIds,
() => handoffAccepted,
).pipe(
Effect.catchCause((cause) =>
(shouldContinue && !handoffAccepted
? Ref.update(desktopContinuationTokens, HashSet.add(requestId))
: Effect.void
).pipe(Effect.andThen(Effect.failCause(cause))),
),
);
}),
});
});
export const make = Effect.fn("cloud.server_self_update.make")(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
const desktopAppUpdate = yield* DesktopAppUpdate.DesktopAppUpdate;
const launcher = yield* ServiceLauncherClient.ServiceLauncherClient;The stable launcher, rather than the child it is about to stop, owns durable active
selection. It writes a pending trial, stops the old server after response-flush
grace, snapshots SQLite database/WAL/shared-memory files, starts the target under an
activation gate, and waits for prepared. Only the launcher commits the new active
version and removes the snapshot. A failed or timed-out trial restores the snapshot
and restarts the old version.
old child: stage V → preflight V → request-update(V)
launcher: record pending → stop old → snapshot SQLite → start V as trial
trial V: migrate + bind + prepare roots → report prepared
launcher: commit V → discard snapshot → let V open activation gate
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const execPath = yield* HostProcessExecutablePath;
const inFlight = yield* Ref.make(false);
const capability: ServerSelfUpdateCapability | null =
serverConfig.mode === "desktop" ? "desktop-managed" : launcher.managed ? "boot-service" : null;
const failWith = (reason: string, cause?: unknown) =>
cause === undefined
? new ServerSelfUpdateError({ reason })
: new ServerSelfUpdateError({ reason, cause });
const update: ServerSelfUpdate["Service"]["update"] = Effect.fn(
"cloud.server_self_update.update",
)(function* (input, reportProgress = () => Effect.void, onHandoffAccepted = () => Effect.void) {
if (capability === "desktop-managed") {
// input.targetVersion is meaningless here: the desktop app's own
// update feed decides what it downloads, and the result carries what
// it actually got.
if (desktopAppUpdate.available) {
return yield* desktopAppUpdate.run(reportProgress);The snapshot is a bounded rollback tool, not a universal undo. It covers the database, WAL, and shared-memory files around the trial; attachments and other state-directory files are outside that boundary. A launcher protocol that is too old blocks a target that needs this safety path. After a commit, ordinary service manager restart policy applies; there is no promise of a second automatic rollback for every later runtime failure.
An accepted update remains pending from the client’s perspective. After reconnect, the client correlates the launcher’s update ID with the ready environment, then checks both outcome and target version; reconnect alone cannot distinguish commit from rollback. Servers without update-ID support retain version-only correlation. Foreground CLI processes do not self-update. Chapter 28 owns the restore-marker and filesystem-sync details of the launcher’s recovery journal.
3. Mobile: OTA eligibility is native compatibility plus a safe teardown moment
Mobile’s Expo Updates configuration uses a fingerprint runtime version. Inference: because the fingerprint is derived from native-project inputs, it is a compatibility gate between an OTA JavaScript bundle and the installed native binary—not a release label intended to synchronize mobile with npm.
},
},
android: {
enabled: true,
singleShareMimeTypes: ["*/*"],
multipleShareMimeTypes: ["*/*"],
},
},
];
// These aliases match the fonts' PostScript names on iOS. Register the same
// names on Android so React Native and the native composer use one set of
// family names without waiting for runtime font loading.
const config: ExpoConfig = {
name: variant.appName,
slug: "t3-code",
platforms: ["ios", "android"],
scheme: variant.scheme,
version: "1.1.1",
runtimeVersion: {
// Development manifests resolve on every launch, so avoid fingerprint'sAfter an eligible bundle downloads, automatic application is deferred to a safe background transition. The update coordinator flushes draft and outbox persistence, then checks that the app is still backgrounded and is not behind an app-initiated foreground handoff such as a picker. A failed flush or unsafe lifecycle state keeps the current runtime alive and rearms a later transition. Explicit user-requested update application is a separate immediate path.
Zoom with the controls, +/−, or Ctrl/⌘ + trackpad scroll. Enable Pan to drag, use two-finger scrolling, or use the arrow keys. 0 fits the diagram; Esc leaves Pan or expanded view.
Text equivalent
Desktop: check, user download, user install, stop backend pool, quit/install. Server: exact runtime stage, preflight, launcher pending state, SQLite snapshot, target trial, prepared signal, commit or rollback. Mobile: fingerprint-compatible OTA check/download, flush drafts/outbox, confirm safe true background, reload or defer.
apps/desktop/src/updates/DesktopUpdates.ts:334–458 ↗apps/desktop/src/updates/DesktopUpdates.ts:465–575 ↗apps/server/src/cloud/selfUpdate.ts:64–169 ↗apps/server/src/cloud/selfUpdate.ts:171–191 ↗docs/internals/server-updates.md:1–58 ↗apps/mobile/app.config.ts:159–180 ↗apps/mobile/src/features/updates/app-updates.ts:1–480 ↗Act III — observability and privacy are separate lanes, not one data pipe
The server’s anonymous PostHog analytics layer is configured on by default but can
be disabled with T3CODE_TELEMETRY_ENABLED. Events remain buffered in memory and
are sent in batches only when delivery is enabled; the payload explicitly disables
PostHog person profiles. The service supplies platform, architecture, client type,
and T3 Code version alongside the event properties.
Client attribution comes from the WebSocket that produced the event. clientType
describes how the server is hosted; surface describes the connected web, desktop,
or mobile client. Missing metadata from an earlier client remains unknown. A
server-global “current client” would misattribute simultaneous surfaces, especially
when a desktop-hosted environment serves a phone or remote browser.
const TelemetryEnvConfig = Config.all({
posthogKey: Config.string("T3CODE_POSTHOG_KEY").pipe(
Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"),
),
posthogHost: Config.string("T3CODE_POSTHOG_HOST").pipe(
Config.withDefault("https://us.i.posthog.com"),
),
enabled: Config.boolean("T3CODE_TELEMETRY_ENABLED").pipe(Config.withDefault(true)),
flushBatchSize: Config.number("T3CODE_TELEMETRY_FLUSH_BATCH_SIZE").pipe(Config.withDefault(20)),
maxBufferedEvents: Config.number("T3CODE_TELEMETRY_MAX_BUFFERED_EVENTS").pipe(
Config.withDefault(1_000),
),
wslDistroName: Config.string("WSL_DISTRO_NAME").pipe(Config.option),
function serverOsFromNodePlatform(platform: string): ClientOs {
switch (platform) {
case "darwin":
return "macOS";
case "win32":
return "Windows";
case "linux":
return "Linux";
case "android":
return "Android";
default:
return "other";
}
}
/** @public Service construction is part of the canonical Effect module API. */
export const make = Effect.gen(function* () {
const telemetryConfig = yield* TelemetryEnvConfig;
const httpClient = yield* HttpClient.HttpClient;
const serverConfig = yield* ServerConfig.ServerConfig;
const identifier = yield* getTelemetryIdentifier;
const bufferRef = yield* Ref.make<ReadonlyArray<BufferedAnalyticsEvent>>([]);
const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client";
const hostPlatform = yield* HostProcessPlatform;
const hostArchitecture = yield* HostProcessArchitecture;
const enqueueBufferedEvent = (event: string, properties?: Readonly<Record<string, unknown>>) =>
Effect.flatMap(DateTime.now, (now) =>
Ref.modify(bufferRef, (current) => {
const appended = [
...current,
{
event,
...(properties ? { properties } : {}),
capturedAt: DateTime.formatIso(now),
} satisfies BufferedAnalyticsEvent,
];
const next =
appended.length > telemetryConfig.maxBufferedEvents
? appended.slice(appended.length - telemetryConfig.maxBufferedEvents)
: appended;
return [
{
size: next.length,
dropped: next.length !== appended.length,
} as const,
next,
] as const;
}),
);
const sendBatch = Effect.fn("AnalyticsService.sendBatch")(function* (
events: ReadonlyArray<BufferedAnalyticsEvent>,
) {
if (!telemetryConfig.enabled || !identifier) return;
const payload = {
api_key: telemetryConfig.posthogKey,
batch: events.map((event) => ({
event: event.event,
distinct_id: identifier,
properties: {
...event.properties,Identity selection is an ordered, hashed ladder: a Codex account id if readable, else a Claude user id, else an installation-scoped anonymous UUID in the T3 home directory. The code hashes the chosen value with its source namespace before returning it. That reduces direct identifier exposure to the analytics service; it does not mean all event properties are automatically anonymous, so the payload schema remains an operational privacy boundary.
Active-use reporting should use client.turn.requested; client.connected counts
reconnections and is therefore sensitive to network behavior. Provider send and
completion totals need not match, and cost comparisons require complete usage with
no observed subagents or mixed-model routing plus matching model, effort,
interaction mode, and terminal status. Partial counts remain useful observations,
but they cannot establish a complete turn total.
/**
* getTelemetryIdentifier - Users are "identified" by finding the first match of the following, then hashing the value.
* 1. ~/.codex/auth.json tokens.account_id
* 2. ~/.claude.json userID
* 3. ~/.t3/telemetry/anonymous-id
*/
export const getTelemetryIdentifierForHome = Effect.fn("getTelemetryIdentifierForHome")(
function* (homeDirectory: string) {
const codexAccountId = yield* getCodexAccountId(homeDirectory).pipe(
Effect.catchTags({
TelemetryIdentityReadError: (error) =>
logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
TelemetryIdentityDecodeError: (error) =>
logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
}),
);
if (Option.isSome(codexAccountId)) {
return yield* hash("codex", codexAccountId.value);
}
const claudeUserId = yield* getClaudeUserId(homeDirectory).pipe(
Effect.catchTags({
TelemetryIdentityReadError: (error) =>
logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
TelemetryIdentityDecodeError: (error) =>
logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
}),
);
if (Option.isSome(claudeUserId)) {
return yield* hash("claude", claudeUserId.value);
}
const anonymousId = yield* upsertAnonymousId.pipe(
Effect.map(Option.some),
Effect.catchTags({
TelemetryIdentityReadError: (error) =>
logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
TelemetryAnonymousIdGenerationError: (error) =>
logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
TelemetryAnonymousIdPersistenceError: (error) =>
logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
}),
);
if (Option.isSome(anonymousId)) {
return yield* hash("anonymous", anonymousId.value);
}
return null;
},
Effect.tapError(logTelemetryIdentityError),
Effect.orElseSucceed(() => null),
);Tracing has a different default. The server creates a bounded local file trace sink and applies HTTP-header redaction. When an OTLP trace URL is configured, the local tracer also delegates to the remote exporter; metrics, by contrast, exist only when an OTLP metrics URL is configured. This is a local-plus-optional-remote trace lane, not a claim that metrics are retained locally.
export const ObservabilityLive = Layer.unwrap(
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const attribution = yield* ResourceAttribution.ResourceAttribution;
const traceReferencesLayer = Layer.mergeAll(
Layer.succeed(Tracer.MinimumTraceLevel, config.traceMinLevel),
Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled),
httpHeaderRedactionLayer,
);
const tracerLayer = Layer.unwrap(
Effect.gen(function* () {
const sink = yield* makeTraceSink({
filePath: config.serverTracePath,
maxBytes: config.traceMaxBytes,
maxFiles: config.traceMaxFiles,
batchWindowMs: config.traceBatchWindowMs,
onFlush: (stats) =>
attribution.record({
component: "server-trace",
operation: "append",
logicalWriteBytes: stats.logicalWriteBytes,
count: stats.count,
durationMs: stats.durationMs,
}),
});
const delegate =
config.otlpTracesUrl === undefined
? undefined
: yield* OtlpTracer.make({
url: config.otlpTracesUrl,
exportInterval: `${config.otlpExportIntervalMs} millis`,
resource: {
serviceName: config.otlpServiceName,
attributes: {
"service.runtime": "t3-server",
"service.mode": config.mode,
},
},
});
const tracer = yield* makeLocalFileTracer({
filePath: config.serverTracePath,
maxBytes: config.traceMaxBytes,
maxFiles: config.traceMaxFiles,
batchWindowMs: config.traceBatchWindowMs,
sink,
...(delegate ? { delegate } : {}),
});
return Layer.mergeAll(
Layer.succeed(Tracer.Tracer, tracer),
BrowserTraceCollector.layer(sink),
);
}),
).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provideMerge(otlpSerializationLayer));
const metricsLayer =
config.otlpMetricsUrl === undefined
? Layer.empty
: OtlpMetrics.layer({
url: config.otlpMetricsUrl,
exportInterval: `${config.otlpExportIntervalMs} millis`,
resource: {
serviceName: config.otlpServiceName,
attributes: {
"service.runtime": "t3-server",
"service.mode": config.mode,
},
},
}).pipe(Layer.provideMerge(otlpSerializationLayer));
return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer);
}),Browser OTLP records follow another route: the server authenticates the raw request
at the operate scope, then attempts to decode and record it locally. A decode or
local-collection failure is logged and recovered; it does not prevent the original
JSON body from being forwarded when a remote trace endpoint is configured. With no
remote URL, the route returns no content after that best-effort local attempt. The
authentication boundary is strict, while local recording and remote forwarding are
deliberately independent delivery lanes.
const end = first === null || last === null || last >= size ? size - 1n : last;
if (!Number.isSafeInteger(Number(start)) || !Number.isSafeInteger(Number(end))) {
return { _tag: "Unsatisfiable" as const };
}
return {
_tag: "Range" as const,
offset: start,
bytesToRead: end - start + 1n,
contentRange: `bytes ${start}-${end}/${size}`,
};
}
export const assetFileResponse = Effect.fn("assetFileResponse")(function* (
asset: {
readonly path: string;
readonly download?: boolean;
readonly fileName?: string;
readonly mimeType?: string;
readonly file?: OpenMediaFile;
},
rangeHeader?: string,
ifRangeHeader?: string,
method: "GET" | "HEAD" = "GET",
) {
const headers = assetResponseHeaders(asset.path, asset);
const mediaFile = asset.file;
const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined;
const isVideo = headers["Content-Type"]?.toLowerCase().startsWith("video/") === true;
if (mediaFile && isVideo) {
// Host videos can change in place. Do not invite conditional range requests
// with validators that cannot establish byte-for-byte identity.
headers["Cache-Control"] = "private, no-store";
}
let status = 200;
let offset = 0n;
let bytesToRead: bigint | undefined;
if (isVideo) {
headers["Accept-Ranges"] = "bytes";
// If-Range requires a matching validator. A full response is safe when we cannot validate it.
if (method === "GET" && rangeHeader && ifRangeHeader === undefined) {
const fs = yield* FileSystem.FileSystem;
const info = mediaInfo ?? (yield* fs.stat(asset.path));
const range = assetByteRange(rangeHeader, info.size);
if (range?._tag === "Unsatisfiable") {
return HttpServerResponse.empty({
status: 416,
headers: { ...headers, "Content-Range": `bytes */${info.size}` },
});
}
if (range?._tag === "Range") {
status = 206;
offset = range.offset;
bytesToRead = range.bytesToRead;
headers["Content-Range"] = range.contentRange;Finally, native resource telemetry is diagnostics-oriented rather than an event archive. A standalone Rust child owns a bounded in-memory history; collector failure does not stop the server, and there is no recurring shell-probe fallback. Age, snapshot count, process rows, and retained bytes are independent limits, so a large process tree shortens the available history. Periodic streaming is off until a diagnostics subscription is retained; explicit refresh still works.
The model samples counters and process trees, so processes that begin and end between samples may not be seen. PID identity includes process start time, and snapshot sequence numbers reset with the monitor generation. Historical replay must not be overwritten with current Electron metrics. Windows packages do not currently provide the Linux monitor needed inside a WSL backend, although the inherited Electron power feed remains available. These are sampled diagnostics, not syscall, eBPF, ETW, or endpoint-security tracing.
## Collection cost
The native child owns sampling and bounded in-memory history. The server requests
continuous snapshots only while diagnostics has live subscribers and fetches
history on demand. Consuming host power for background scheduling must not retain
live diagnostics. There is no telemetry database or recurring shell-probe fallback.
History has independent bounds for age, snapshot count, process rows, and retained
bytes. A count limit alone cannot bound memory when command lines vary in size.
Large process trees therefore shorten the available history window. Linux task
enumeration is disabled because walking every `/proc/<pid>/task/<tid>` directory
makes sampling itself expensive.
Electron power updates travel over private inherited pipes, independent of the
renderer connection. Power events and slow heartbeats continue with diagnostics
closed; `app.getAppMetrics()` runs only on live demand. The receiver's stale deadline
must exceed the slowest configured heartbeat plus scheduling grace, or intentional
idle polling makes background policy oscillate between constrained and
unconstrained states. Headless servers leave unavailable power data unknown.
## Measurement traps
- Process identity includes start time because operating systems reuse PIDs.
Electron and native start times have different precision, so merging allows a
small tolerance. Process signaling rechecks the native identity with a fresh
sample.
- Snapshot sequence numbers belong to a monitor generation. Comparing them across
restarts would discard the new monitor's samples until its sequence caught up.
- Sampling can miss a process that starts and exits between samples. Cumulative
counters still yield deltas for processes observed across samples.
- Windows process I/O includes more than disk traffic. Unix counters report storage
I/O, which can differ from logical application reads and writes because of OS
caching. Keep instrumented logical I/O separate from these counters.
- Group totals accumulate observed deltas since telemetry started. Per-process
cumulative counters cover the operating system's lifetime for that process.
- Historical replay uses native samples without current Electron CPU or memory
metrics. Merging the latest Electron values would overwrite the past.
A WSL backend needs a Linux monitor even though Electron runs on Windows. Windows
desktop packages currently supply only the Windows executable, so native process
telemetry for the WSL backend is unavailable. The inherited Electron power feed
still works.One release, three replacement boundaries, four signal lanes
Choose a lens, select a teaching case, and move the marker yourself. The model shows the point at which each owner may proceed—and the boundary that keeps it from doing so.
Release graph · stable exact-version path
Publishing the CLI unblocks every client that can request a server update
The graph does not update a machine. It only makes a matching, exact runtime available before a client can advertise itself.
- Guard
- publish `t3@V` before a client at V is exposed
- Owner now
- release workflow
- Failure boundary
- a client cannot target a package that has not published
Release graph. Step 1.
Static operations ledger
| Flow | Proceed only after | Owner | If it cannot proceed |
|---|---|---|---|
| Exact server update | `t3@<version>` publishes before client release | release workflow, then service launcher | client version cannot be offered before the matching runtime exists |
| Desktop application | user chooses download and then install; pooled backends stop | desktop updater | state records an error or remains downloaded for a later install |
| Boot-service server | exact runtime preflight, SQLite snapshot, target reports prepared | stable launcher | rollback restores snapshot and selects the old runtime |
| Mobile OTA | fingerprint-compatible bundle, flushed intent, safe true background | mobile update coordinator | defer to a later lifecycle transition |
| Diagnostics | destination and authorization vary by lane | analytics/tracer/resource services | local traces can remain local; resource history stays bounded in memory |
The operational reading is therefore concise: release ordering makes an exact runtime available; each platform crosses its own replacement boundary; diagnostics retain, export, and authorize different things on purpose.