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
21 changes: 21 additions & 0 deletions apps/daemon/src/app-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { dirname, join, parse as parsePath } from 'node:path';
import { releaseChannelFromVersion } from '@open-design/release';

export const APP_VERSION_FALLBACK = '0.0.0';
export const UNKNOWN_APP_VERSION = 'unknown';

// Keep this structurally aligned with `@open-design/contracts` AppVersionInfo.
// Daemon cannot import the package root type directly yet because its NodeNext
Expand All @@ -17,6 +18,26 @@ export interface AppVersionInfo {
arch: string;
}

export function normalizeTelemetryAppVersion(value: unknown): string | null {
const version = cleanString(value);
return version && version !== APP_VERSION_FALLBACK && version !== UNKNOWN_APP_VERSION
? version
: null;
}

export function normalizeTelemetryAppVersionInfo(value: unknown): AppVersionInfo | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const candidate = value as Partial<AppVersionInfo>;
const version = normalizeTelemetryAppVersion(candidate.version);
const channel = cleanString(candidate.channel);
const platform = cleanString(candidate.platform);
const arch = cleanString(candidate.arch);
if (!version || !channel || !platform || !arch || typeof candidate.packaged !== 'boolean') {
return null;
}
return { version, channel, packaged: candidate.packaged, platform, arch };
}

interface PackageMetadata {
version?: unknown;
}
Expand Down
33 changes: 29 additions & 4 deletions apps/daemon/src/observability/task-observation-rollout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
type RunTelemetryDeliveryStateV1,
type RunTelemetryDeliveryResult,
} from './delivery-state.js';
import { normalizeTelemetryAppVersion } from '../app-version.js';
import { buildStructuredMainRunObservationV1 } from './main-run-observation.js';
import { getDetectedRuntimeVersions } from '../runtimes/detection.js';
import { OD_NEXT_RUNTIME_PATH_DESCRIPTORS } from '../runtimes/od-next-capability-gate.js';
Expand Down Expand Up @@ -108,6 +109,13 @@ interface TaskRunLike {
langfuseCompletedAt?: number;
telemetryDelivery?: RunTelemetryDeliveryStateV1;
strategyRolloutDecision?: OdNextRolloutDecision | null;
appVersionInfo?: {
version: string;
channel: string;
packaged: boolean;
platform?: string;
arch?: string;
} | null;
}

type PersistedDeliveryStatus =
Expand Down Expand Up @@ -206,6 +214,21 @@ function cleanContextValue(value: string | undefined): string | null {
return CONTEXT_VALUE_RE.test(normalized) ? normalized : null;
}

function runAppVersionInfoForTask(
task: StrategyTaskExecutionRecord,
options: CreateTaskObservationRolloutServiceOptions,
): { version: string; channel: string; packaged: boolean } | null {
for (const mapping of task.runs) {
const candidate = options.getRun(mapping.runId)?.appVersionInfo;
const version = normalizeTelemetryAppVersion(candidate?.version);
const channel = candidate?.channel?.trim();
if (version && channel && typeof candidate?.packaged === 'boolean') {
return { version, channel, packaged: candidate.packaged };
}
}
return null;
}

export function readTaskObservationRolloutConfig(
env: NodeJS.ProcessEnv = process.env,
): TaskObservationRolloutConfig {
Expand Down Expand Up @@ -1209,17 +1232,19 @@ export function createTaskObservationRolloutService(
aggregate = await taskAggregate(task, options, telemetry);
recordAggregate(task.taskExecutionId, aggregate);
sink = effectiveSink();
const appVersionInfo = runAppVersionInfoForTask(task, options)
?? telemetry.appVersionInfo;
exportContext = {
environment: claim.row.environment,
tag: claim.row.tag,
...(telemetry.installationId !== undefined
? { installationId: telemetry.installationId }
: {}),
...(telemetry.appVersionInfo
...(appVersionInfo
? {
appVersion: telemetry.appVersionInfo.version,
appChannel: telemetry.appVersionInfo.channel,
packaged: telemetry.appVersionInfo.packaged,
appVersion: appVersionInfo.version,
appChannel: appVersionInfo.channel,
packaged: appVersionInfo.packaged,
}
: {}),
clientType: task.runs
Expand Down
18 changes: 12 additions & 6 deletions apps/daemon/src/routes/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from '../analytics.js';
import type { AnalyticsContext } from '../analytics.js';
import type { readAppConfig, writeAppConfig } from '../app-config.js';
import { readCurrentAppVersionInfo } from '../app-version.js';
import { readCurrentAppVersionInfo, UNKNOWN_APP_VERSION } from '../app-version.js';
import { reportRunFeedbackFromDaemon } from '../langfuse-bridge.js';
import { observePendingInstallerApplyAttempts } from '../migration/index.js';
import {
Expand All @@ -24,6 +24,7 @@ export interface DaemonTelemetry {
analyticsService: ReturnType<typeof createAnalyticsService>;
disposeFatalHandlers: () => void;
getCachedAppVersion: () => any;
resolveAppVersion: () => Promise<any>;
reportFeedback: (req: {
runId: string;
rating: 'positive' | 'negative';
Expand Down Expand Up @@ -216,7 +217,7 @@ export function registerTelemetryRoutes(app: Express, deps: RegisterTelemetryRou
await analyticsService.capture({
eventName: body.event,
context,
appVersion: cachedAppVersion?.version ?? '0.0.0',
appVersion: cachedAppVersion?.version ?? UNKNOWN_APP_VERSION,
properties,
insertId: body.eventId,
});
Expand All @@ -236,7 +237,7 @@ export function registerTelemetryRoutes(app: Express, deps: RegisterTelemetryRou
: {};
analyticsService.captureSafety({
eventName,
appVersion: cachedAppVersion?.version ?? '0.0.0',
appVersion: cachedAppVersion?.version ?? UNKNOWN_APP_VERSION,
properties,
});
res.json({ ok: true });
Expand All @@ -247,27 +248,32 @@ export function registerTelemetryRoutes(app: Express, deps: RegisterTelemetryRou
getAppVersion: () => cachedAppVersion,
});

void (async () => {
const appVersionPromise = (async () => {
try {
cachedAppVersion = await readCurrentAppVersionInfo();
await observePendingInstallerApplyAttempts({
void observePendingInstallerApplyAttempts({
analytics: analyticsService,
appVersion: cachedAppVersion.version,
currentChannel: cachedAppVersion.channel,
currentVersion: cachedAppVersion.version,
dataRoot: dataDir,
logger: console,
namespace: process.env[SIDECAR_ENV.NAMESPACE] ?? SIDECAR_DEFAULTS.namespace,
}).catch(() => {
// Update-apply telemetry must not delay daemon version readiness.
});
return cachedAppVersion;
} catch {
// Telemetry is best-effort; appVersion is omitted when unavailable.
return null;
}
})();

return {
analyticsService,
disposeFatalHandlers,
getCachedAppVersion: () => cachedAppVersion,
resolveAppVersion: () => appVersionPromise,
reportFeedback: (req) =>
reportRunFeedbackFromDaemon({
dataDir,
Expand Down Expand Up @@ -593,7 +599,7 @@ function installFatalTelemetryHandlers({
try {
await analyticsService.captureSafety({
eventName,
appVersion: getAppVersion()?.version ?? '0.0.0',
appVersion: getAppVersion()?.version ?? UNKNOWN_APP_VERSION,
properties,
});
} catch {
Expand Down
28 changes: 25 additions & 3 deletions apps/daemon/src/runtimes/run-terminal-reconciliation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ import {
type RunTelemetryDeliveryResult,
type RunTelemetryDeliveryStateV1,
} from '../observability/delivery-state.js';
import {
normalizeTelemetryAppVersion,
normalizeTelemetryAppVersionInfo,
UNKNOWN_APP_VERSION,
type AppVersionInfo,
} from '../app-version.js';

const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'canceled']);
const RECONCILED_STATUS_MESSAGE = 'Run terminal state reconciled after daemon restart.';
Expand All @@ -47,6 +53,7 @@ interface DurableRunState extends RestartRecoverableDurableRunState {
conversationId: string | null;
assistantMessageId: string | null;
agentId: string | null;
appVersionInfo?: AppVersionInfo;
cancelOrigin?: TrackingRunCancelOrigin | null;
terminalTrigger?: TrackingRunTerminalTrigger | null;
createdAt: number;
Expand Down Expand Up @@ -82,7 +89,7 @@ interface AnalyticsLike {

interface ReconciliationOptions {
analytics: AnalyticsLike;
appVersion: string;
appVersion?: string;
appVersionInfo?: unknown;
db: Database.Database;
reportLangfuse(args: Record<string, unknown>): unknown | Promise<unknown>;
Expand All @@ -105,6 +112,21 @@ interface ReconciliationOptions {
finalizeTerminalLocally?: (run: DurableRunState, status: string, terminalAt: number) => void;
}

function appVersionForRun(state: DurableRunState, options: ReconciliationOptions): string {
return normalizeTelemetryAppVersionInfo(state.appVersionInfo)?.version
?? normalizeTelemetryAppVersionInfo(options.appVersionInfo)?.version
?? normalizeTelemetryAppVersion(options.appVersion)
?? UNKNOWN_APP_VERSION;
}

function appVersionInfoForRun(
state: DurableRunState,
options: ReconciliationOptions,
): AppVersionInfo | null {
return normalizeTelemetryAppVersionInfo(state.appVersionInfo)
?? normalizeTelemetryAppVersionInfo(options.appVersionInfo);
}

export interface RunTerminalReconciliationResult {
scanned: number;
interrupted: number;
Expand Down Expand Up @@ -473,7 +495,7 @@ export async function reconcileDurableRunTerminals(
await Promise.resolve(options.analytics.capture({
eventName: 'run_finished',
context: state.analyticsRecovery.context,
appVersion: options.appVersion,
appVersion: appVersionForRun(state, options),
properties,
insertId: `${state.analyticsRecovery.insertId}-finish`,
}));
Expand Down Expand Up @@ -564,7 +586,7 @@ export async function reconcileDurableRunTerminals(
run: hydrateRun(state, events),
persistedRunStatus: state.status,
persistedEndedAt: state.updatedAt,
appVersion: options.appVersionInfo ?? null,
appVersion: appVersionInfoForRun(state, options),
deliveryIdempotencyKey: state.telemetryDelivery.idempotencyKey,
onDeliveryAttempt: () => {
state.telemetryDelivery = recordRunTelemetryDeliveryAttempt(
Expand Down
13 changes: 13 additions & 0 deletions apps/daemon/src/runtimes/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
finalizeRunTelemetryDelivery,
recordRunTelemetryDeliveryAttempt,
} from '../observability/delivery-state.js';
import { normalizeTelemetryAppVersionInfo } from '../app-version.js';

export const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'canceled']);

Expand Down Expand Up @@ -527,6 +528,7 @@ function durableRunState(run) {
? { strategyRolloutDecision: run.strategyRolloutDecision }
: {}),
agentId: run.agentId,
...(run.appVersionInfo ? { appVersionInfo: run.appVersionInfo } : {}),
status: run.status,
createdAt: run.createdAt,
updatedAt: run.updatedAt,
Expand Down Expand Up @@ -679,6 +681,9 @@ export function createChatRunService({
// durable but before the terminal SSE event is published, so local outbox
// writes share the exact terminal timestamp without delaying on delivery.
onTerminal = null,
// Snapshot the daemon version at Run creation so a later daemon version
// cannot rewrite this Run's terminal telemetry during restart recovery.
getAppVersionInfo = () => null,
}) {
const runs = new Map();
const runIdsByClientRequestId = new Map();
Expand Down Expand Up @@ -815,6 +820,13 @@ export function createChatRunService({
const create = (meta = {}) => {
const now = Date.now();
const id = randomUUID();
let appVersionInfo = null;
try {
appVersionInfo = normalizeTelemetryAppVersionInfo(getAppVersionInfo());
} catch {
// Version attribution is best-effort; missing is explicit in the
// durable state instead of persisting a placeholder release number.
}
const run = {
id,
projectId: typeof meta.projectId === 'string' && meta.projectId ? meta.projectId : null,
Expand All @@ -832,6 +844,7 @@ export function createChatRunService({
? meta.strategyRolloutDecision
: null,
agentId: typeof meta.agentId === 'string' && meta.agentId ? meta.agentId : null,
appVersionInfo,
projectMetadata:
meta.projectMetadata && typeof meta.projectMetadata === 'object' && !Array.isArray(meta.projectMetadata)
? meta.projectMetadata
Expand Down
27 changes: 20 additions & 7 deletions apps/daemon/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,11 @@ export {
signDesktopImportToken,
verifyDesktopImportToken,
} from './desktop-auth.js';
import { readCurrentAppVersionInfo } from './app-version.js';
import {
normalizeTelemetryAppVersionInfo,
readCurrentAppVersionInfo,
UNKNOWN_APP_VERSION,
} from './app-version.js';
import {
findSkillById,
listSkills,
Expand Down Expand Up @@ -7458,11 +7462,19 @@ export async function startServer({
readAppConfig,
writeAppConfig,
});
const resolvedAppVersionInfo = normalizeTelemetryAppVersionInfo(
await telemetry.resolveAppVersion(),
);
const currentAppVersionInfo = () =>
normalizeTelemetryAppVersionInfo(telemetry.getCachedAppVersion())
?? resolvedAppVersionInfo;
const currentAppVersion = () =>
currentAppVersionInfo()?.version ?? UNKNOWN_APP_VERSION;
const { analyticsService } = telemetry;
registerStrategyRolloutRoutes(app, {
db,
analytics: analyticsService,
getAppVersion: () => telemetry.getCachedAppVersion()?.version ?? '0.0.0',
getAppVersion: currentAppVersion,
requireLocalDaemonRequest,
// Uncaught on purpose: an operator asking which mode is in effect must get
// an error when the config cannot be read, never `off` / `default`.
Expand All @@ -7473,7 +7485,7 @@ export async function startServer({
db,
analytics: analyticsService,
analyticsContext: run.analyticsContext,
appVersion: telemetry.getCachedAppVersion()?.version ?? '0.0.0',
appVersion: currentAppVersion(),
mode,
reasonCode,
// A thunk, not a value: the latch is the safety action and must land
Expand All @@ -7495,6 +7507,7 @@ export async function startServer({
createSseResponse,
createSseErrorPayload,
runsLogDir: path.join(RUNTIME_DATA_DIR, 'runs'),
getAppVersionInfo: currentAppVersionInfo,
// Fold committed side effects into a truncation-proof per-run ledger as
// each event is emitted, so the finalization verdict (retry safety gate,
// artifact_count, close-status artifactProducedThisRun) does not depend on
Expand All @@ -7519,7 +7532,7 @@ export async function startServer({
},
}),
analytics: analyticsService,
getAppVersion: () => telemetry.getCachedAppVersion()?.version ?? '0.0.0',
getAppVersion: currentAppVersion,
readAnalyticsContext,
};
const internalRunCreation = createInternalRunCreationService({
Expand Down Expand Up @@ -7562,8 +7575,8 @@ export async function startServer({
// stays off the startup critical path.
void reconcileDurableRunTerminals({
analytics: analyticsService,
appVersion: telemetry.getCachedAppVersion()?.version ?? '0.0.0',
appVersionInfo: telemetry.getCachedAppVersion(),
appVersion: currentAppVersion(),
appVersionInfo: currentAppVersionInfo(),
db,
reportLangfuse: reportRunCompletedFromDaemon,
finalizeTerminalLocally: createAmrTerminalReportFinalizer(amrTerminalReportOutbox),
Expand Down Expand Up @@ -8240,7 +8253,7 @@ export async function startServer({
await analyticsService.capture({
eventName,
context: analyticsContext,
appVersion: telemetry.getCachedAppVersion()?.version ?? '0.0.0',
appVersion: currentAppVersion(),
properties,
insertId: newInsertId(),
});
Expand Down
Loading
Loading