feat: track A2A proposal funnel events
All checks were successful
CI and Production Smoke / smoke (push) Successful in 9s
All checks were successful
CI and Production Smoke / smoke (push) Successful in 9s
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<main className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
<div className="mx-auto grid min-h-screen max-w-7xl gap-8 px-5 py-6 lg:grid-cols-[1.5fr_0.9fr] lg:px-8">
|
||||
|
||||
@@ -9,6 +9,13 @@ const MONITOR_TOKEN = process.env.TRAFFIC_MONITOR_TOKEN;
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
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({
|
||||
<div className="text-3xl font-bold mt-2 text-emerald-300">{summary.channelSummary.external}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
|
||||
<div className="text-gray-400 text-sm">曝光(OPEN_TASKS)</div>
|
||||
<div className="text-gray-400 text-sm">A2A 曝光</div>
|
||||
<div className="text-3xl font-bold mt-2 text-cyan-300">{conversionSummary.discovery_events}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
|
||||
<div className="text-gray-400 text-sm">提案頁查看</div>
|
||||
<div className="text-3xl font-bold mt-2 text-sky-300">{conversionSummary.proposal_view_events}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
|
||||
<div className="text-gray-400 text-sm">提案建立</div>
|
||||
<div className="text-3xl font-bold mt-2 text-violet-300">{conversionSummary.proposal_created_events}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
|
||||
<div className="text-gray-400 text-sm">提案付款成功</div>
|
||||
<div className="text-3xl font-bold mt-2 text-emerald-300">{conversionSummary.proposal_paid_events}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
|
||||
<div className="text-gray-400 text-sm">外部接案</div>
|
||||
<div className="text-3xl font-bold mt-2 text-blue-300">{conversionSummary.claim_events}</div>
|
||||
@@ -703,6 +757,36 @@ export default async function TrafficDashboard({
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">外部流量轉化漏斗</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>A2A曝光→提案頁</span>
|
||||
<span className="text-emerald-300">{fmtPercent(conversionRates.proposal_view_rate)}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-sky-400"
|
||||
style={{ width: `${Math.min(conversionRates.proposal_view_rate, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>提案頁→建立</span>
|
||||
<span className="text-emerald-300">{fmtPercent(conversionRates.proposal_create_rate)}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-violet-400"
|
||||
style={{ width: `${Math.min(conversionRates.proposal_create_rate, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>建立→付款成功</span>
|
||||
<span className="text-emerald-300">{fmtPercent(conversionRates.proposal_paid_rate)}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-400"
|
||||
style={{ width: `${Math.min(conversionRates.proposal_paid_rate, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>曝光→接案</span>
|
||||
<span className="text-emerald-300">{fmtPercent(conversionRates.claim_rate)}</span>
|
||||
@@ -745,6 +829,8 @@ export default async function TrafficDashboard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-gray-300 space-y-1">
|
||||
<div className="flex justify-between"><span>Stripe checkout 開始</span><span>{conversionSummary.proposal_checkout_events}</span></div>
|
||||
<div className="flex justify-between"><span>錢包付款待確認</span><span>{conversionSummary.proposal_wallet_pending_events}</span></div>
|
||||
<div className="flex justify-between"><span>PASS 任務</span><span>{conversionSummary.judge_pass_events}</span></div>
|
||||
<div className="flex justify-between"><span>FAIL 任務</span><span>{conversionSummary.judge_fail_events}</span></div>
|
||||
<div className="flex justify-between"><span>已出金</span><span>{conversionSummary.payout_captured}</span></div>
|
||||
|
||||
167
apps/web/src/lib/a2a-traffic.ts
Normal file
167
apps/web/src/lib/a2a-traffic.ts
Normal file
@@ -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<string, unknown>;
|
||||
}) {
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user