diff --git a/apps/web/src/app/api/a2a/growth/kit/route.ts b/apps/web/src/app/api/a2a/growth/kit/route.ts index 3bf1da8..ecbb1fd 100644 --- a/apps/web/src/app/api/a2a/growth/kit/route.ts +++ b/apps/web/src/app/api/a2a/growth/kit/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { buildAgentGrowthKit, sanitizeAgentId } from "@/lib/a2a-growth"; +import { logA2aTrafficEvent } from "@/lib/a2a-traffic"; export const dynamic = "force-dynamic"; @@ -52,6 +53,23 @@ export async function GET(request: NextRequest) { }, }); + await logA2aTrafficEvent({ + headers: request.headers, + fallbackAgentId: agentId, + action: "EXTERNAL_A2A_GROWTH_KIT_ISSUED", + surface: "a2a/growth-kit", + entityId: "a2a-growth-kit", + reason: "external_agent_growth_kit_issued", + metadata: { + campaign, + source, + registered_pending_agent: shouldRegister, + referral_url: kit.referral_url, + response_status: 200, + response_summary: "a2a_growth_kit_issued", + }, + }); + return NextResponse.json({ success: true, kit, diff --git a/apps/web/src/app/api/a2a/integrations/route.ts b/apps/web/src/app/api/a2a/integrations/route.ts index af52feb..1bcbf4c 100644 --- a/apps/web/src/app/api/a2a/integrations/route.ts +++ b/apps/web/src/app/api/a2a/integrations/route.ts @@ -1,12 +1,34 @@ import { NextRequest, NextResponse } from "next/server"; import { buildA2aIntegrationCatalog } from "@/lib/a2a-agent-integrations"; import { buildAgentGrowthKit, sanitizeAgentId } from "@/lib/a2a-growth"; +import { logA2aTrafficEvent } from "@/lib/a2a-traffic"; export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { const agentId = sanitizeAgentId(request.nextUrl.searchParams.get("agent_id")); const catalog = buildA2aIntegrationCatalog(agentId || null); + const action = agentId + ? "EXTERNAL_A2A_INTEGRATION_CATALOG_VIEW" + : "INTERNAL_A2A_INTEGRATION_CATALOG_VIEW"; + + void logA2aTrafficEvent({ + headers: request.headers, + fallbackAgentId: agentId || null, + action, + surface: "a2a/integrations", + entityId: "a2a-integration-catalog", + reason: agentId ? "external_agent_catalog_discovery" : "catalog_discovery", + metadata: { + agent_id: agentId || null, + integration_count: catalog.integrations.length, + telegram_roles: catalog.telegram_control_plane.length, + response_status: 200, + response_summary: `a2a_integrations_ok:${catalog.integrations.length}`, + }, + }).catch((error) => { + console.warn("[a2a/integrations] traffic audit failed", error); + }); return NextResponse.json({ success: true, diff --git a/apps/web/src/app/api/traffic/route.ts b/apps/web/src/app/api/traffic/route.ts index afff533..e791b3b 100644 --- a/apps/web/src/app/api/traffic/route.ts +++ b/apps/web/src/app/api/traffic/route.ts @@ -503,8 +503,15 @@ export async function GET(request: NextRequest) { const discoveryEvents = (actionSummary["EXTERNAL_LIST_OPEN_TASKS"] || 0) + - (actionSummary["EXTERNAL_LIST_OPEN_TASKS_MCP"] || 0); + (actionSummary["EXTERNAL_LIST_OPEN_TASKS_MCP"] || 0) + + (actionSummary["EXTERNAL_A2A_INTEGRATION_CATALOG_VIEW"] || 0) + + (actionSummary["EXTERNAL_A2A_GROWTH_KIT_ISSUED"] || 0); + const proposalViewEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_VIEW"] || 0; + const proposalCreatedEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED"] || 0; + const proposalCheckoutEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED"] || 0; + const proposalWalletPendingEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_WALLET_PAYMENT_PENDING"] || 0; + const proposalPaidEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED"] || 0; const claimEvents = actionSummary["EXTERNAL_CLAIM_TASK_SUCCESS"] || 0; const submitEvents = actionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0; const judgePassEvents = judgeCompleteRows.filter((row) => { @@ -523,6 +530,11 @@ export async function GET(request: NextRequest) { const externalFunnel = { discovery_events: discoveryEvents, + proposal_view_events: proposalViewEvents, + proposal_created_events: proposalCreatedEvents, + proposal_checkout_events: proposalCheckoutEvents, + proposal_wallet_pending_events: proposalWalletPendingEvents, + proposal_paid_events: proposalPaidEvents, claim_events: claimEvents, submit_events: submitEvents, judge_pass_events: judgePassEvents, @@ -533,6 +545,9 @@ export async function GET(request: NextRequest) { }; const conversionRates = { + proposal_view_rate: conversionRate(proposalViewEvents, discoveryEvents), + proposal_create_rate: conversionRate(proposalCreatedEvents, proposalViewEvents), + proposal_paid_rate: conversionRate(proposalPaidEvents, proposalCreatedEvents), claim_rate: conversionRate(claimEvents, discoveryEvents), submit_rate: conversionRate(submitEvents, claimEvents), pass_rate: conversionRate(judgePassEvents, submitEvents), diff --git a/apps/web/src/app/api/webhooks/stripe/route.ts b/apps/web/src/app/api/webhooks/stripe/route.ts index 2fb2da5..56f3c56 100644 --- a/apps/web/src/app/api/webhooks/stripe/route.ts +++ b/apps/web/src/app/api/webhooks/stripe/route.ts @@ -4,6 +4,7 @@ import Stripe from "stripe"; import { TaskStatus } from "@agent-bounty/contracts"; import { broadcastFomoEvent } from "@/lib/x-broadcaster"; import { sanitizeAgentId } from "@/lib/a2a-growth"; +import { resolveA2aTrafficContext } from "@/lib/a2a-traffic"; const stripe = process.env.STRIPE_SECRET_KEY ? new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: "2026-05-27.dahlia", @@ -14,13 +15,16 @@ function getStripeObjectId(value: string | Stripe.PaymentIntent | Stripe.SetupIn return value?.id || null; } -async function handleDemandProposalFee(session: Stripe.Checkout.Session) { +async function handleDemandProposalFee(session: Stripe.Checkout.Session, requestHeaders: Headers) { const metadata = session.metadata || {}; const taskId = metadata.task_id; const referralAgent = sanitizeAgentId(metadata.referral_agent); const proposalFeeCents = Number(metadata.proposal_fee_cents || session.amount_total || 0); const paymentIntentId = getStripeObjectId(session.payment_intent); const idempotencyKey = `proposal-fee:${session.id}`; + const trafficContext = referralAgent + ? resolveA2aTrafficContext(requestHeaders, referralAgent) + : null; const task = await prisma.task.findFirst({ where: taskId @@ -131,6 +135,37 @@ async function handleDemandProposalFee(session: Stripe.Checkout.Session) { }, }, }); + + if (referralAgent && trafficContext) { + await tx.auditEvent.create({ + data: { + actorType: trafficContext.actor_type, + actorId: trafficContext.actor_id, + action: "EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED", + entityType: "TASK", + entityId: task.id, + reason: "referred_demand_proposal_payment_captured", + metadata: { + stripe_session_id: session.id, + stripe_payment_intent_id: paymentIntentId, + fee_cents: proposalFeeCents, + package_id: metadata.package_id || null, + referral_agent: referralAgent, + affiliate_fee_cents: Math.floor(proposalFeeCents * 0.1), + source: metadata.source || null, + campaign: metadata.campaign || null, + request_id: trafficContext.request_id, + source_ip: trafficContext.source_ip, + user_agent: trafficContext.user_agent, + is_public_ip: trafficContext.is_public_ip, + surface: "stripe/webhook", + request_actor_headers: trafficContext.request_actor_headers, + response_status: 200, + response_summary: "demand_proposal_fee_captured", + }, + }, + }); + } }); console.log(`[Webhook] Proposal fee captured for task ${task.id}. Payment Intent: ${paymentIntentId}`); @@ -164,7 +199,7 @@ export async function POST(request: NextRequest) { if (event.type === "checkout.session.completed") { const session = event.data.object as Stripe.Checkout.Session; if (session.metadata?.intent === "DEMAND_PROPOSAL_FEE") { - await handleDemandProposalFee(session); + await handleDemandProposalFee(session, request.headers); return NextResponse.json({ received: true }); } diff --git a/apps/web/src/app/propose/actions.ts b/apps/web/src/app/propose/actions.ts index c46dacc..9a9ab50 100644 --- a/apps/web/src/app/propose/actions.ts +++ b/apps/web/src/app/propose/actions.ts @@ -1,6 +1,7 @@ "use server"; import { prisma } from "@/lib/prisma"; +import { logA2aTrafficEvent } from "@/lib/a2a-traffic"; import { getProposalPackage, sanitizeAgentId, @@ -9,6 +10,7 @@ import { VIBEWORK_SITE_URL, } from "@/lib/a2a-growth"; import { TaskDifficulty, TaskStatus } from "@agent-bounty/contracts"; +import { headers } from "next/headers"; import { redirect } from "next/navigation"; import Stripe from "stripe"; @@ -68,6 +70,7 @@ function buildCancelUrl(referralAgent: string, packageId: string) { } export async function createDemandProposal(formData: FormData) { + const requestHeaders = await headers(); const packageId = getString(formData, "packageId"); const proposalPackage = getProposalPackage(packageId); const referralAgent = sanitizeAgentId(getString(formData, "refAgent")); @@ -178,6 +181,29 @@ export async function createDemandProposal(formData: FormData) { }, }); + if (referralAgent) { + await logA2aTrafficEvent({ + headers: requestHeaders, + fallbackAgentId: referralAgent, + action: "EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED", + surface: "propose/action", + entityType: "TASK", + entityId: task.id, + reason: "referred_demand_proposal_submitted", + metadata: { + package_id: proposalPackage.id, + proposal_fee_cents: proposalPackage.feeCents, + budget_cents: budgetCents, + source, + campaign, + referral_agent: referralAgent, + payment_method: paymentMethod, + response_status: 200, + response_summary: "demand_proposal_intake_created", + }, + }); + } + if (paymentMethod === "wallet" || !stripe) { await prisma.auditEvent.create({ data: { @@ -195,6 +221,25 @@ export async function createDemandProposal(formData: FormData) { }, }); + if (referralAgent) { + await logA2aTrafficEvent({ + headers: requestHeaders, + fallbackAgentId: referralAgent, + action: "EXTERNAL_DEMAND_PROPOSAL_WALLET_PAYMENT_PENDING", + surface: "propose/action", + entityType: "TASK", + entityId: task.id, + reason: "wallet_payment_instructions_issued", + metadata: { + package_id: proposalPackage.id, + amount_cents: proposalPackage.feeCents, + referral_agent: referralAgent, + response_status: 200, + response_summary: "wallet_payment_instructions_issued", + }, + }); + } + redirect(buildSuccessUrl(task.id, { payment: "wallet", package: proposalPackage.id, @@ -244,5 +289,25 @@ export async function createDemandProposal(formData: FormData) { throw new Error("Stripe checkout session URL is missing."); } + if (referralAgent) { + await logA2aTrafficEvent({ + headers: requestHeaders, + fallbackAgentId: referralAgent, + action: "EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED", + surface: "propose/action", + entityType: "TASK", + entityId: task.id, + reason: "stripe_checkout_started", + metadata: { + stripe_session_id: session.id, + package_id: proposalPackage.id, + amount_cents: proposalPackage.feeCents, + referral_agent: referralAgent, + response_status: 303, + response_summary: "stripe_checkout_started", + }, + }); + } + redirect(session.url); } diff --git a/apps/web/src/app/propose/page.tsx b/apps/web/src/app/propose/page.tsx index 9b253db..2ba3412 100644 --- a/apps/web/src/app/propose/page.tsx +++ b/apps/web/src/app/propose/page.tsx @@ -1,6 +1,8 @@ import { createDemandProposal } from "@/app/propose/actions"; import { buildAgentGrowthKit, PROPOSAL_PACKAGES, sanitizeAgentId } from "@/lib/a2a-growth"; +import { logA2aTrafficEvent } from "@/lib/a2a-traffic"; import { ArrowRight, Bot, CreditCard, Network, Users, Wallet } from "lucide-react"; +import { headers } from "next/headers"; import Link from "next/link"; export const dynamic = "force-dynamic"; @@ -23,6 +25,30 @@ export default async function ProposePage({ searchParams }: { searchParams?: Sea ? buildAgentGrowthKit({ agentId: referralAgent, campaign, source }) : null; + if (referralAgent) { + const requestHeaders = await headers(); + await logA2aTrafficEvent({ + headers: requestHeaders, + fallbackAgentId: referralAgent, + action: "EXTERNAL_DEMAND_PROPOSAL_VIEW", + surface: "propose", + entityType: "SYSTEM", + entityId: "demand-proposal-intake", + reason: "referred_demand_proposer_landed", + metadata: { + referral_agent: referralAgent, + campaign, + source, + package_id: packageId, + cancelled, + response_status: 200, + response_summary: "demand_proposal_view", + }, + }).catch((error) => { + console.warn("[propose] traffic audit failed", error); + }); + } + return (
diff --git a/apps/web/src/app/traffic/page.tsx b/apps/web/src/app/traffic/page.tsx index 145ba9c..0e453ba 100644 --- a/apps/web/src/app/traffic/page.tsx +++ b/apps/web/src/app/traffic/page.tsx @@ -9,6 +9,13 @@ const MONITOR_TOKEN = process.env.TRAFFIC_MONITOR_TOKEN; const EVENT_LABELS: Record = { EXTERNAL_LIST_OPEN_TASKS: "外部公開流量頁讀取 open tasks", + EXTERNAL_A2A_INTEGRATION_CATALOG_VIEW: "外部 Agent 讀取 A2A 整合目錄", + EXTERNAL_A2A_GROWTH_KIT_ISSUED: "外部 Agent 領取 growth kit", + EXTERNAL_DEMAND_PROPOSAL_VIEW: "外部導流需求方查看提案頁", + EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED: "外部導流需求方建立提案", + EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED: "外部導流需求方開始 Stripe 結帳", + EXTERNAL_DEMAND_PROPOSAL_WALLET_PAYMENT_PENDING: "外部導流需求方取得錢包付款指示", + EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED: "外部導流提案費付款成功", EXTERNAL_LIST_OPEN_TASKS_SURGE: "外部公開流量突增告警", EXTERNAL_LIST_OPEN_TASKS_MCP: "外部 MCP 入口讀取 open tasks", EXTERNAL_LIST_OPEN_TASKS_MCP_SURGE: "外部 MCP 流量突增告警", @@ -377,7 +384,14 @@ async function getTrafficSummary(minutes: number) { const discoveryEvents = (actionSummary["EXTERNAL_LIST_OPEN_TASKS"] || 0) + - (actionSummary["EXTERNAL_LIST_OPEN_TASKS_MCP"] || 0); + (actionSummary["EXTERNAL_LIST_OPEN_TASKS_MCP"] || 0) + + (actionSummary["EXTERNAL_A2A_INTEGRATION_CATALOG_VIEW"] || 0) + + (actionSummary["EXTERNAL_A2A_GROWTH_KIT_ISSUED"] || 0); + const proposalViewEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_VIEW"] || 0; + const proposalCreatedEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED"] || 0; + const proposalCheckoutEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED"] || 0; + const proposalWalletPendingEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_WALLET_PAYMENT_PENDING"] || 0; + const proposalPaidEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED"] || 0; const claimEvents = actionSummary["EXTERNAL_CLAIM_TASK_SUCCESS"] || 0; const submitEvents = actionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0; const judgePassEvents = judgeCompleteRows.filter((row) => { @@ -391,6 +405,11 @@ async function getTrafficSummary(minutes: number) { const conversionSummary = { discovery_events: discoveryEvents, + proposal_view_events: proposalViewEvents, + proposal_created_events: proposalCreatedEvents, + proposal_checkout_events: proposalCheckoutEvents, + proposal_wallet_pending_events: proposalWalletPendingEvents, + proposal_paid_events: proposalPaidEvents, claim_events: claimEvents, submit_events: submitEvents, judge_pass_events: judgePassEvents, @@ -400,6 +419,9 @@ async function getTrafficSummary(minutes: number) { }; const conversionRates = { + proposal_view_rate: percent(proposalViewEvents, discoveryEvents), + proposal_create_rate: percent(proposalCreatedEvents, proposalViewEvents), + proposal_paid_rate: percent(proposalPaidEvents, proposalCreatedEvents), claim_rate: percent(claimEvents, discoveryEvents), submit_rate: percent(submitEvents, claimEvents), pass_rate: percent(judgePassEvents, submitEvents), @@ -557,12 +579,20 @@ function toLocalTime(value: Date) { } function buildConversionTips(summary: { + proposal_view_rate: number; + proposal_create_rate: number; + proposal_paid_rate: number; claim_rate: number; submit_rate: number; pass_rate: number; payout_rate: number; }, conversionSummary: { discovery_events: number; + proposal_view_events: number; + proposal_created_events: number; + proposal_checkout_events: number; + proposal_wallet_pending_events: number; + proposal_paid_events: number; claim_events: number; submit_events: number; judge_pass_events: number; @@ -572,6 +602,18 @@ function buildConversionTips(summary: { }) { const steps: string[] = []; + if (conversionSummary.discovery_events > 0 && conversionSummary.proposal_view_events === 0) { + steps.push("A2A 曝光已有資料但提案頁查看為零:優先確認 growth kit referral_url、vibework.wooo.work/propose 代理與外部貼文 CTA。"); + } + + if (conversionSummary.proposal_view_events > 0 && conversionSummary.proposal_created_events === 0) { + steps.push("有提案頁查看但無送出:優先檢查表單文案、套裝價格、付款方式與 mobile 可用性。"); + } + + if (conversionSummary.proposal_created_events > 0 && conversionSummary.proposal_paid_events === 0) { + steps.push("已有提案但尚未付款成功:分流檢查 Stripe checkout 與 USDC wallet receipt verification。"); + } + if (conversionSummary.discovery_events > 0 && conversionSummary.claim_events === 0) { steps.push("曝光高但接單為零:請先檢查 open-tasks 回傳任務文案是否足夠明確,是否包含 npx 指令與標準格式。"); } @@ -657,9 +699,21 @@ export default async function TrafficDashboard({
{summary.channelSummary.external}
-
曝光(OPEN_TASKS)
+
A2A 曝光
{conversionSummary.discovery_events}
+
+
提案頁查看
+
{conversionSummary.proposal_view_events}
+
+
+
提案建立
+
{conversionSummary.proposal_created_events}
+
+
+
提案付款成功
+
{conversionSummary.proposal_paid_events}
+
外部接案
{conversionSummary.claim_events}
@@ -703,6 +757,36 @@ export default async function TrafficDashboard({

外部流量轉化漏斗

+
+ A2A曝光→提案頁 + {fmtPercent(conversionRates.proposal_view_rate)} +
+
+
+
+
+ 提案頁→建立 + {fmtPercent(conversionRates.proposal_create_rate)} +
+
+
+
+
+ 建立→付款成功 + {fmtPercent(conversionRates.proposal_paid_rate)} +
+
+
+
曝光→接案 {fmtPercent(conversionRates.claim_rate)} @@ -745,6 +829,8 @@ export default async function TrafficDashboard({
+
Stripe checkout 開始{conversionSummary.proposal_checkout_events}
+
錢包付款待確認{conversionSummary.proposal_wallet_pending_events}
PASS 任務{conversionSummary.judge_pass_events}
FAIL 任務{conversionSummary.judge_fail_events}
已出金{conversionSummary.payout_captured}
diff --git a/apps/web/src/lib/a2a-traffic.ts b/apps/web/src/lib/a2a-traffic.ts new file mode 100644 index 0000000..f0eedc5 --- /dev/null +++ b/apps/web/src/lib/a2a-traffic.ts @@ -0,0 +1,167 @@ +import { randomUUID } from "node:crypto"; +import { isIP } from "node:net"; +import { prisma } from "@/lib/prisma"; + +type HeaderReader = { + get(name: string): string | null; +}; + +const REQUEST_ID_HEADERS = ["x-request-id", "x-correlation-id", "x-trace-id"]; +const ACTOR_HEADERS = ["x-agent-id", "x-agent-name", "x-ai-agent-id", "x-ai-id"]; +const AI_USER_AGENT_HINTS = [ + "gpt", + "chatgpt", + "openai", + "anthropic", + "claude", + "perplexity", + "llm", + "mcp", + "autogpt", + "agent", + "assistant", + "gemini", + "cursor", + "copilot", + "aider", + "openhands", + "openclaw", + "hermes", +]; + +export function normalizeA2aActorId(value: string | null | undefined, fallback = "agent") { + const normalized = (value || fallback) + .trim() + .toLowerCase() + .replace(/[^a-z0-9._:-]+/g, "_") + .replace(/_+/g, "_") + .slice(0, 80); + return normalized || fallback; +} + +export function asA2aAgentActorId(value: string | null | undefined, fallback = "agent") { + const normalized = normalizeA2aActorId(value, fallback); + return normalized.startsWith("agent:") ? normalized : `agent:${normalized}`; +} + +function sanitizeIpAddress(value: string | null | undefined) { + if (!value) return "unknown"; + const first = value.split(",")[0]?.trim(); + if (!first) return "unknown"; + + const bracketedMatch = first.match(/^\[(.+)\]:(\d+)$/); + if (bracketedMatch?.[1]) return bracketedMatch[1].toLowerCase(); + + const ipv4WithPortMatch = first.match(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/); + if (ipv4WithPortMatch?.[1]) return ipv4WithPortMatch[1]; + + return first.toLowerCase(); +} + +export function resolveA2aSourceIp(headers: HeaderReader) { + return sanitizeIpAddress( + headers.get("x-forwarded-for") ?? + headers.get("x-real-ip") ?? + headers.get("cf-connecting-ip") ?? + headers.get("true-client-ip") ?? + headers.get("x-client-ip") ?? + "unknown" + ); +} + +export function isPrivateA2aSourceIp(ip: string | undefined) { + if (!ip) return true; + const normalized = ip.trim().toLowerCase(); + if (!normalized || normalized === "unknown" || normalized === "localhost") return true; + if (normalized === "::1" || normalized.startsWith("fe80")) return true; + if (isIP(normalized) === 6 && (normalized.startsWith("fc") || normalized.startsWith("fd"))) return true; + if (isIP(normalized) === 0) return false; + if (normalized.startsWith("127.") || normalized.startsWith("10.") || normalized.startsWith("192.168.")) return true; + if (normalized.startsWith("172.")) { + const secondOctet = Number(normalized.split(".")[1]); + return secondOctet >= 16 && secondOctet <= 31; + } + return false; +} + +function resolveRequestId(headers: HeaderReader) { + for (const headerName of REQUEST_ID_HEADERS) { + const value = headers.get(headerName); + if (value) return value; + } + return randomUUID(); +} + +function resolveHeaderActor(headers: HeaderReader) { + for (const headerName of ACTOR_HEADERS) { + const value = headers.get(headerName); + if (value) return asA2aAgentActorId(value); + } + return null; +} + +function isLikelyAiUserAgent(userAgent: string) { + const normalized = userAgent.toLowerCase(); + return AI_USER_AGENT_HINTS.some((token) => normalized.includes(token)); +} + +export function resolveA2aTrafficContext(headers: HeaderReader, fallbackAgentId?: string | null) { + const sourceIp = resolveA2aSourceIp(headers); + const userAgent = headers.get("user-agent") || "unknown"; + const headerActorId = resolveHeaderActor(headers); + const actorId = + fallbackAgentId + ? asA2aAgentActorId(fallbackAgentId) + : headerActorId || (isLikelyAiUserAgent(userAgent) ? asA2aAgentActorId(userAgent) : `external:${sourceIp}`); + + return { + request_id: resolveRequestId(headers), + source_ip: sourceIp, + user_agent: userAgent, + is_public_ip: !isPrivateA2aSourceIp(sourceIp), + actor_type: actorId.startsWith("agent:") ? ("AGENT" as const) : ("USER" as const), + actor_id: actorId, + request_actor_headers: { + x_agent_id: headers.get("x-agent-id"), + x_agent_name: headers.get("x-agent-name"), + x_ai_agent_id: headers.get("x-ai-agent-id"), + x_ai_id: headers.get("x-ai-id"), + x_request_id: headers.get("x-request-id"), + origin: headers.get("origin"), + referer: headers.get("referer"), + }, + }; +} + +export async function logA2aTrafficEvent(params: { + headers: HeaderReader; + fallbackAgentId?: string | null; + action: string; + surface: string; + entityType?: "TASK" | "CLAIM" | "SUBMISSION" | "JUDGE_RESULT" | "SYSTEM"; + entityId: string; + reason?: string; + metadata?: Record; +}) { + const context = resolveA2aTrafficContext(params.headers, params.fallbackAgentId); + + return prisma.auditEvent.create({ + data: { + actorType: context.actor_type, + actorId: context.actor_id, + action: params.action, + entityType: params.entityType || "SYSTEM", + entityId: params.entityId, + reason: params.reason, + metadata: { + ...params.metadata, + request_id: context.request_id, + source_ip: context.source_ip, + user_agent: context.user_agent, + is_public_ip: context.is_public_ip, + surface: params.surface, + request_actor_headers: context.request_actor_headers, + }, + }, + }); +}