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
4 changes: 4 additions & 0 deletions app/(auth)/auth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { withErrorHandler } from "@/lib/api/error-handler";
import { TIER_RANK } from "@/lib/navigation";
import { sanitizeInternalRedirect } from "@/lib/auth/safe-redirect";
import logger from "@/lib/logger";
import { phase0 } from "@/lib/observability/phase0-trace";

export const GET = withErrorHandler(async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url);
Expand Down Expand Up @@ -41,6 +42,9 @@ export const GET = withErrorHandler(async function GET(request: NextRequest) {
"[auth-callback] Session exchange failed",
);
}
// [phase0] relay.oauth.connect — additive, after the real auth result is known. Failure-isolated
// (emit() is internally try/catch); never gates or reorders the redirect logic below.
phase0.oauthConnect({ provider: "google", result: error ? "error" : "success" });
if (!error) {
const { data: { user } } = await supabase.auth.getUser();
if (user) {
Expand Down
12 changes: 12 additions & 0 deletions app/api/v1/action-map/briefs/[id]/approve/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { withErrorHandler } from "@/lib/api/error-handler";
import { buildOutcomeLedgerSeed } from "@/lib/outcomes";
import { phase0 } from "@/lib/observability/phase0-trace";

type ApprovalOpportunity = {
id: string;
Expand Down Expand Up @@ -124,6 +125,17 @@ export const POST = withErrorHandler(async function POST(request: NextRequest) {
}

opportunity = approvedOpportunity as ApprovalOpportunity;

// [phase0] relay.action.approve — additive, AFTER the recovery_opportunities update succeeds.
// In-app approve surface. mode='live'; channel='email' (the eventual outreach channel for this
// recovery action). Failure-isolated; runs after the real write, never gates the loop.
phase0.approve({
tenantId: profile.tenant_id,
actionId: id,
channel: "email",
approver: user.id,
mode: "live",
});
} else if (!isAlreadyApproved) {
failed++;
continue;
Expand Down
12 changes: 12 additions & 0 deletions app/api/v1/actions/approve/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest } from "next/server";
import { verifyActionToken } from "@/lib/actions/token";
import { getSupabaseAdmin } from "@/lib/supabase/admin";
import logger from "@/lib/logger";
import { phase0 } from "@/lib/observability/phase0-trace";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -88,6 +89,17 @@ export async function GET(request: NextRequest) {
);
}

// [phase0] relay.action.approve — additive, AFTER the recovery_items approve update succeeds.
// mode='live' (real one-tap email approval, not a projected demo). Failure-isolated; runs after
// the real write, so even a throw could not undo the approval. channel='email' (deep-link path).
phase0.approve({
tenantId: item.tenant_id,
actionId: item.id,
channel: "email",
approver: payload.userId,
mode: "live",
});

// Queue outreach if there's a suggested action
if (item.suggested_action) {
await supabase.from("outreach_queue").insert({
Expand Down
81 changes: 81 additions & 0 deletions lib/observability/phase0-trace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Phase 0 trace shim — OAuth -> approve -> send -> receipt, the first wire.
*
* STATUS: NO-DEPLOY-RISK FIRST WIRE. Dependency-free. Records the Phase-0 span name + honesty
* attributes to the repo's EXISTING structured logger (pino, `@/lib/logger`), wrapped so it can
* NEVER throw into the caller. This makes the Phase-0 path observable in runtime logs today
* without adding any npm dependency, SDK, exporter, or migration.
*
* Contract: docs/specs/PHASE0-OTEL-TRACE-OAUTH-SEND-RECEIPT-2026-06-15.md (+ phase0-trace.stub.ts).
* The stable attribute keys (`oauth.result`, `approval.mode`, `send.status`, `receipt.amount_cents`,
* etc.) mirror that spec. `approval.mode=live|projected` and `send.status=held` are the honesty
* guards: a HOLD or a projected (not-live) run stays visible, so a demo can never be mistaken for a
* real recovered dollar (keeps the website/console payment vs first-recovered-dollar line honest).
*
* DEPLOY-TIME FOLLOW-UP (documented, not done here): swap the `emit()` body for the real
* `@opentelemetry/api` SDK + Langfuse OTLP exporter at these SAME call-sites — the public `phase0.*`
* surface stays identical, so wiring those call-sites once is the whole cost. Confirm the Langfuse
* OTLP path + tracer API via Context7 before that swap (CONTEXT7 ALWAYS).
*/

import logger from "@/lib/logger";

type Attrs = Record<string, string | number | boolean | undefined>;

/**
* Internal sink. Failure-isolated by design: the entire body is wrapped in try/catch so a logger
* fault (or a swapped-in OTel exporter fault, later) can never propagate into the auth / approve /
* send / receipt path it is observing.
*/
function emit(span: string, attrs: Attrs): void {
try {
logger.info({ phase0_span: span, ...attrs }, "[phase0] " + span);
} catch {
/* never throw into the caller — observability must not break the critical path */
}
}

export const phase0 = {
/** Stage 1 — OAuth connect (console). `result`=success|invalid_nonce|error. */
oauthConnect(a: {
tenantId?: string;
provider: string;
result: string;
durationMs?: number;
}): void {
emit("relay.oauth.connect", a);
},

/** Stage 2 — Approve (console). `mode`=live|projected is the honesty guard. */
approve(a: {
tenantId?: string;
actionId: string;
channel?: string;
approver?: string;
mode: string;
}): void {
emit("relay.action.approve", a);
},

/** Stage 3 — Send (relay-pulse). `status`=sent|failed|held. Wired at the pulse send path. */
send(a: {
actionId: string;
provider: string;
status: string;
messageId?: string;
durationMs?: number;
}): void {
emit("relay.send.dispatch", a);
},

/** Stage 4 — Receipt (relay-pulse). The one measured number; `attributed` gates demo vs real. */
receipt(a: {
actionId: string;
type: string;
amountCents: number;
attributed: boolean;
latencyMs?: number;
}): void {
emit("relay.receipt.attribute", a);
},
};