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
29 changes: 28 additions & 1 deletion .github/workflows/deploy-router.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,36 @@ jobs:
- name: Install dependencies
run: npm ci

# Worker secrets ship atomically with the deploy. Only secrets that are
# present are written; an absent ANALYTICS_VISITOR_HASH_SECRET leaves
# guide-fetch analytics failing closed (no data points).
- name: Prepare optional Worker secrets
shell: bash
env:
ANALYTICS_VISITOR_HASH_SECRET: ${{ secrets.ANALYTICS_VISITOR_HASH_SECRET }}
run: |
set -euo pipefail
umask 077
export ROUTER_SECRETS_FILE="$RUNNER_TEMP/router-secrets.json"
node --input-type=module <<'NODE'
import { writeFileSync } from "node:fs";

const names = ["ANALYTICS_VISITOR_HASH_SECRET"];
const secrets = Object.fromEntries(names.flatMap((name) => {
const value = process.env[name];
return value ? [[name, value]] : [];
}));
writeFileSync(process.env.ROUTER_SECRETS_FILE, JSON.stringify(secrets), { mode: 0o600 });
NODE

- name: Deploy router to Cloudflare
working-directory: router
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
run: ../node_modules/.bin/wrangler deploy
run: ../node_modules/.bin/wrangler deploy --secrets-file "$RUNNER_TEMP/router-secrets.json"
Comment thread
khaliqgant marked this conversation as resolved.

- name: Remove temporary Worker secrets file
if: always()
shell: bash
run: rm -f "$RUNNER_TEMP/router-secrets.json"
47 changes: 43 additions & 4 deletions router/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { maybeRecord, type RecorderEnv } from "./src/recorder.js";
import { maybeRateLimit, type RateLimitEnv } from "./src/rate-limit.js";
import { recordGuideFetched, type AnalyticsEnv } from "./src/analytics.js";

interface Env {
interface Env extends AnalyticsEnv {
CLOUD_APP_ORIGIN: string;
CLOUD_WEB_WORKER?: {
fetch(request: Request): Promise<Response>;
Expand Down Expand Up @@ -214,9 +215,33 @@ export function prefersHtmlOverMarkdown(accept: string | null): boolean {
return acceptQuality(accept, "text/html") > acceptQuality(accept, "text/markdown");
}

// Any /u/<handle> page or guide, on any host, after the rewrite above.
// Any /u/<handle> page, guide, or owner dashboard, on any host, after the
// rewrite above. The whole /u/ namespace is excluded, not a list of known
// suffixes, so no dashboard variant can slip past the recorder.
export function isAgentPageRequestPath(pathname: string): boolean {
return /^\/u\/[^/]+(?:\/agent\.md)?\/?$/i.test(pathname);
return /^\/u(?:\/|$)/i.test(pathname);
}

// Dashboard grants are single-use bearer credentials. Any request carrying a
// grant parameter, on any path, stays out of the replay corpus.
export function carriesDashboardGrant(url: URL): boolean {
let found = false;
url.searchParams.forEach((_value, key) => {
if (key.toLowerCase() === "grant") found = true;
});
return found;
}

// The handle whose agent guide this request negotiated, when it is an external
// GET that analytics may count. HTML, HEAD and Worker subrequests never count.
export function getGuideAnalyticsHandle(
request: Request,
agentPagePath: string | undefined,
): string | undefined {
if (request.method !== "GET" || !agentPagePath || request.headers.has("cf-worker")) {
return undefined;
}
return /^\/u\/([a-z0-9-]+)\/agent\.md$/.exec(agentPagePath)?.[1];
}

// The page first shipped at agentrelay.com/agent-relay; keep that link working.
Expand Down Expand Up @@ -630,6 +655,11 @@ export default {
return Response.redirect(legacyAgentPage, 301);
}

const guideAnalyticsHandle = getGuideAnalyticsHandle(request, agentPagePath);
// Keep the original request: rewrites build new Requests, which do not
// carry the incoming request.cf (and with it the country).
const guideAnalyticsRequest = request;

if (agentPagePath && agentPagePath !== url.pathname.replace(/\/$/, "")) {
url.pathname = agentPagePath;
request = new Request(url.toString(), request);
Expand All @@ -656,7 +686,10 @@ export default {
// content, so neither the new route nor the existing Cloud fallback belongs
// in the replay corpus. Agent pages and guides mint a conversation URL in
// every response, so they stay out too, however they were reached.
const recorderRequestClone = recorderEnv && !relayAgentRoute && !isAgentPageRequestPath(url.pathname)
const recorderRequestClone = recorderEnv
&& !relayAgentRoute
&& !isAgentPageRequestPath(url.pathname)
&& !carriesDashboardGrant(url)
? (request.clone() as unknown as Request)
: null;

Expand Down Expand Up @@ -827,6 +860,12 @@ export default {
ctx.waitUntil(maybeRecord(recordingRequest, response.clone(), env, ctx));
}

// Count a guide fetch only once the upstream guide answered 2xx;
// not-found, suspended, redirect and error responses write nothing.
if (guideAnalyticsHandle && upstreamResponse.status >= 200 && upstreamResponse.status < 300) {
ctx.waitUntil(recordGuideFetched(guideAnalyticsRequest, guideAnalyticsHandle, env));
}
Comment thread
khaliqgant marked this conversation as resolved.

return response;
} catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), {
Expand Down
148 changes: 148 additions & 0 deletions router/src/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Server-side arelay.to analytics: the router's half of relay-agent's
// docs/ANALYTICS_BRIEF.md. The router writes exactly one event,
// agent_guide_fetched, using the same ordered v1 schema as relay-agent's
// src/analytics.ts. Keep the two layouts identical; the shared tests pin it.
//
// Writes are best-effort and never change a response. No data point carries a
// URL, query string, grant, raw IP, raw User-Agent, or caller-supplied header.

export const ANALYTICS_SCHEMA_VERSION = "v1";
export const ANALYTICS_WRITER = "router";

export type AnalyticsClientClass = "curl" | "claude-code" | "codex" | "grok" | "other";

export interface AnalyticsDataset {
writeDataPoint(point: {
indexes?: string[];
blobs?: string[];
doubles?: number[];
}): void;
}

export interface AnalyticsEnv {
ARELAY_ANALYTICS?: AnalyticsDataset;
ANALYTICS_VISITOR_HASH_SECRET?: string;
}

const HANDLE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])$/;
const COUNTRY = /^[A-Z][A-Z0-9]$/;

const CLIENT_MARKERS: ReadonlyArray<[AnalyticsClientClass, readonly string[]]> = [
["claude-code", ["claude-code", "claude-cli", "claudecode", "claude-user"]],
["codex", ["codex"]],
["grok", ["grok", "xai-"]],
["curl", ["curl/"]],
];

export function classifyClient(userAgent: string | null | undefined): AnalyticsClientClass {
if (typeof userAgent !== "string" || userAgent.length === 0 || userAgent.length > 1_024) {
return "other";
}
const lowered = userAgent.toLowerCase();
for (const [client, markers] of CLIENT_MARKERS) {
if (markers.some((marker) => lowered.includes(marker))) return client;
}
return "other";
}

/** Reads `request.cf.country`; caller-controlled headers are never consulted. */
export function requestCountry(request: Request): string {
const country = (request as Request & { cf?: { country?: unknown } }).cf?.country;
if (typeof country !== "string") return "unknown";
const upper = country.toUpperCase();
return COUNTRY.test(upper) ? upper : "unknown";
}

const encoder = new TextEncoder();

async function hmac(key: ArrayBuffer | Uint8Array<ArrayBuffer>, message: string): Promise<ArrayBuffer> {
const imported = await crypto.subtle.importKey(
"raw",
key,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
return crypto.subtle.sign("HMAC", imported, encoder.encode(message));
}

function hex(bytes: ArrayBuffer): string {
return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}

/**
* Daily, handle-scoped visitor pseudonym (identical to relay-agent's). Returns
* undefined when the secret is unavailable or hashing fails; the caller then
* writes nothing.
*/
export async function visitorPseudonym(
secret: string | undefined,
handle: string,
ip: string,
userAgent: string,
now = Date.now(),
): Promise<string | undefined> {
if (!secret) return undefined;
try {
const day = new Date(now).toISOString().slice(0, 10);
const dayKey = await hmac(encoder.encode(secret), `arelay-visitors-v1\0${day}`);
return hex(await hmac(dayKey, `${handle}\0${ip}\0${userAgent}`));
} catch {
return undefined;
}
}

export function guideFetchedDataPoint(
handle: string,
client: AnalyticsClientClass,
country: string,
visitor: string,
): { indexes: string[]; blobs: string[]; doubles: number[] } | null {
if (!HANDLE.test(handle) || !/^[0-9a-f]{64}$/.test(visitor)) return null;
return {
indexes: [handle],
blobs: [
ANALYTICS_SCHEMA_VERSION,
"agent_guide_fetched",
handle,
"not_applicable",
client,
COUNTRY.test(country) ? country : "unknown",
"none",
visitor,
ANALYTICS_WRITER,
],
doubles: [1, -1],
};
}

/**
* Records one successful agent guide fetch. Call only after the rewritten
* /u/<handle>/agent.md upstream returned 2xx for an external GET.
*/
export async function recordGuideFetched(
request: Request,
handle: string,
env: AnalyticsEnv,
): Promise<void> {
const dataset = env.ARELAY_ANALYTICS;
if (!dataset) return;
try {
const visitor = await visitorPseudonym(
env.ANALYTICS_VISITOR_HASH_SECRET,
handle,
request.headers.get("cf-connecting-ip")?.trim() || "unknown",
request.headers.get("user-agent") ?? "",
);
if (!visitor) return;
const point = guideFetchedDataPoint(
handle,
classifyClient(request.headers.get("user-agent")),
requestCountry(request),
visitor,
);
if (point) dataset.writeDataPoint(point);
} catch {
console.warn("arelay analytics: data point write failed");
}
}
Loading
Loading