Skip to content

Commit d516dcd

Browse files
add: anonymous telemetry support
1 parent 1b09185 commit d516dcd

3 files changed

Lines changed: 1336 additions & 162 deletions

File tree

src/server/core/review.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { FormatterService } from '../services/formatter';
1414
import { TokenTracker } from './token-tracker';
1515
import { loadRepoConfig } from './config';
1616
import { getWebhookDelivery } from '@server/db/webhook-deliveries';
17+
import { sendTelemetryEvent } from './telemetry';
1718

1819
type PersistedReviewJob = ReturnType<typeof mapJob>;
1920

@@ -694,6 +695,31 @@ async function runFinalizePhase(
694695

695696
if (fileSummaries.length > 0 && fileSummaries.every((file) => file.verdict === 'failed')) {
696697
await updateJobStep(env, job.id, 'Generating Summary', { status: 'failed', error: 'All files failed to review' });
698+
699+
// Send anonymous telemetry for the failed job
700+
const fileInputTokens = reviews.reduce((sum, review) => sum + (review.input_tokens ?? 0), 0);
701+
const fileOutputTokens = reviews.reduce((sum, review) => sum + (review.output_tokens ?? 0), 0);
702+
const modelsUsed = Array.from(new Set(reviews.map(r => r.model_used).filter(Boolean)));
703+
const fileExtensions = Array.from(new Set(files.map(f => {
704+
const parts = f.path.split('.');
705+
return parts.length > 1 ? parts.pop() || '' : '';
706+
}).filter(Boolean)));
707+
const reviewDurationMs = Math.max(0, Date.now() - new Date(job.createdAt).getTime());
708+
709+
await sendTelemetryEvent(env, {
710+
linesReviewed: files.reduce((sum, file) => sum + file.lineCount, 0),
711+
findingsReported: 0,
712+
inputTokens: fileInputTokens,
713+
outputTokens: fileOutputTokens,
714+
modelsUsed,
715+
fileExtensions,
716+
triggerType: job.trigger,
717+
reviewDurationMs,
718+
filesReviewed: files.length,
719+
verdict: 'failed',
720+
severityDistribution: {},
721+
});
722+
697723
throw new Error('All files failed to review');
698724
}
699725

@@ -762,6 +788,20 @@ async function runFinalizePhase(
762788

763789
const fileInputTokens = reviews.reduce((sum, review) => sum + (review.input_tokens ?? 0), 0);
764790
const fileOutputTokens = reviews.reduce((sum, review) => sum + (review.output_tokens ?? 0), 0);
791+
792+
const modelsUsed = Array.from(new Set(reviews.map(r => r.model_used).filter(Boolean)));
793+
const fileExtensions = Array.from(new Set(files.map(f => {
794+
const parts = f.path.split('.');
795+
return parts.length > 1 ? parts.pop() || '' : '';
796+
}).filter(Boolean)));
797+
const reviewDurationMs = Math.max(0, Date.now() - new Date(job.createdAt).getTime());
798+
799+
const severityDistribution: Record<string, number> = {};
800+
for (const comment of finalComments) {
801+
const sev = comment.severity || 'unknown';
802+
severityDistribution[sev] = (severityDistribution[sev] || 0) + 1;
803+
}
804+
765805
const partialErrorMessage = hasFailures
766806
? `Partial review: ${failedFileCount} of ${files.length} file${files.length === 1 ? '' : 's'} could not be reviewed after repeated model/provider outages.`
767807
: null;
@@ -777,6 +817,21 @@ async function runFinalizePhase(
777817
errorMessage: partialErrorMessage,
778818
});
779819
logger.info(`Review job completed: ${job.owner}/${job.repo} PR #${job.prNumber}`);
820+
821+
// Send anonymous telemetry
822+
await sendTelemetryEvent(env, {
823+
linesReviewed: files.reduce((sum, file) => sum + file.lineCount, 0),
824+
findingsReported: finalComments.length,
825+
inputTokens: fileInputTokens,
826+
outputTokens: fileOutputTokens,
827+
modelsUsed,
828+
fileExtensions,
829+
triggerType: job.trigger,
830+
reviewDurationMs,
831+
filesReviewed: files.length,
832+
verdict: verdictSummary.verdict,
833+
severityDistribution,
834+
});
780835
}
781836

782837
async function heartbeatAndCheckSuperseded(env: AppBindings, jobId: string, leaseOwner: string) {

src/server/core/telemetry.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type { AppBindings } from '@server/env';
2+
import { logger } from './logger';
3+
4+
const TELEMETRY_SECRET = 'codra-telemetry-v1-secret-8f9a2b5c';
5+
const INSTANCE_ID_KEY = 'codra:instance_id';
6+
7+
/**
8+
* Returns a stable, anonymous instance ID.
9+
* Generates and stores one in KV if it doesn't exist yet.
10+
*/
11+
export async function getInstanceId(env: AppBindings): Promise<string> {
12+
try {
13+
let instanceId = await env.APP_KV.get(INSTANCE_ID_KEY);
14+
if (!instanceId) {
15+
instanceId = crypto.randomUUID();
16+
await env.APP_KV.put(INSTANCE_ID_KEY, instanceId);
17+
}
18+
return instanceId;
19+
} catch (error) {
20+
logger.warn('Failed to retrieve or generate instance ID for telemetry', {
21+
error: error instanceof Error ? error.message : String(error),
22+
});
23+
// Fallback to a random UUID so telemetry can still send, though it will
24+
// count as a new "install" if KV is failing.
25+
return crypto.randomUUID();
26+
}
27+
}
28+
29+
/**
30+
* Sends an anonymous telemetry event to Codra Core backend.
31+
* Swallows all errors so the caller is never interrupted.
32+
*/
33+
export async function sendTelemetryEvent(
34+
env: AppBindings,
35+
data: {
36+
linesReviewed: number;
37+
findingsReported: number;
38+
inputTokens: number;
39+
outputTokens: number;
40+
modelsUsed: string[];
41+
fileExtensions: string[];
42+
triggerType: string;
43+
reviewDurationMs: number;
44+
filesReviewed: number;
45+
verdict?: string;
46+
severityDistribution: Record<string, number>;
47+
},
48+
): Promise<void> {
49+
try {
50+
const instanceId = await getInstanceId(env);
51+
// Use an environment variable if available, otherwise default to the hosted backend
52+
const telemetryUrl = (env as any).TELEMETRY_API_URL ?? 'https://codra.run/api/telemetry';
53+
54+
// Fire and forget using standard fetch with a timeout
55+
const controller = new AbortController();
56+
const timeoutId = setTimeout(() => controller.abort(), 5000);
57+
58+
await fetch(telemetryUrl, {
59+
method: 'POST',
60+
headers: {
61+
'Content-Type': 'application/json',
62+
'Authorization': `Bearer ${TELEMETRY_SECRET}`,
63+
},
64+
body: JSON.stringify({
65+
instanceId,
66+
prsReviewed: 1,
67+
linesReviewed: data.linesReviewed,
68+
findingsReported: data.findingsReported,
69+
inputTokens: data.inputTokens,
70+
outputTokens: data.outputTokens,
71+
modelsUsed: data.modelsUsed,
72+
fileExtensions: data.fileExtensions,
73+
triggerType: data.triggerType,
74+
reviewDurationMs: data.reviewDurationMs,
75+
filesReviewed: data.filesReviewed,
76+
verdict: data.verdict,
77+
severityDistribution: data.severityDistribution,
78+
}),
79+
signal: controller.signal,
80+
}).catch((error) => {
81+
// Intentionally swallowed: Network errors are expected occasionally
82+
logger.debug('Failed to send anonymous telemetry event (network)', {
83+
error: error instanceof Error ? error.message : String(error),
84+
});
85+
}).finally(() => {
86+
clearTimeout(timeoutId);
87+
});
88+
} catch (error) {
89+
// Intentionally swallowed: We never want telemetry to fail a PR review
90+
logger.debug('Failed to send anonymous telemetry event (setup)', {
91+
error: error instanceof Error ? error.message : String(error),
92+
});
93+
}
94+
}

0 commit comments

Comments
 (0)