@canonry/canonry 7.22.0 → 7.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +14 -0
  2. package/assets/assets/{AuditHistoryPanel-J8AR3x5F.js → AuditHistoryPanel-DKKnX8IT.js} +1 -1
  3. package/assets/assets/{BacklinksPage-CRDPutGA.js → BacklinksPage-DNbur8am.js} +1 -1
  4. package/assets/assets/{HistoryPage-C5taVW3t.js → HistoryPage-DahtHlpM.js} +1 -1
  5. package/assets/assets/{MeasurementPropertyPage-txucmLuz.js → MeasurementPropertyPage-BoNDtLfp.js} +1 -1
  6. package/assets/assets/{ProjectPage-C4FaBoWI.js → ProjectPage--ZlpQduH.js} +1 -1
  7. package/assets/assets/{RunsPage-D-3XKUhh.js → RunsPage-DKB2CHTM.js} +1 -1
  8. package/assets/assets/{SettingsPage-Cq_SJRj_.js → SettingsPage-lnmMZzEj.js} +1 -1
  9. package/assets/assets/{SiteHealthSection-Cn_IZVO1.js → SiteHealthSection-BBjucRed.js} +3 -3
  10. package/assets/assets/{TrafficPage-BMdFZs3X.js → TrafficPage-DNyTmPLd.js} +1 -1
  11. package/assets/assets/{TrafficSourceDetailPage-BmrIOc-2.js → TrafficSourceDetailPage-D1zKZHSh.js} +1 -1
  12. package/assets/assets/{extract-error-message-HlQy5L7O.js → extract-error-message-v0b8P-wc.js} +1 -1
  13. package/assets/assets/index-CfaBRiBG.js +86 -0
  14. package/assets/assets/{react-sigma_core.esm.min-CogAr1i4.js → react-sigma_core.esm.min-BZSJoRrV.js} +1 -1
  15. package/assets/assets/{v2-overview-adapter-tps7TUxq.js → v2-overview-adapter-DXAl-O_h.js} +1 -1
  16. package/assets/index.html +1 -1
  17. package/dist/{chunk-222NAFTY.js → chunk-7Q7QCBJ3.js} +2 -2
  18. package/dist/{chunk-5IXQAOVK.js → chunk-DS2ZKB3T.js} +15 -6
  19. package/dist/{chunk-MRKV43TV.js → chunk-IM2BFAAT.js} +565 -206
  20. package/dist/{chunk-NDQOCFJQ.js → chunk-JJBVYL45.js} +39 -4
  21. package/dist/{chunk-46JZR2IV.js → chunk-O5UMRQVN.js} +1303 -504
  22. package/dist/{chunk-6Z2LFFRT.js → chunk-SG3MPCDL.js} +5731 -4596
  23. package/dist/{chunk-6H2BC5EY.js → chunk-YCPTP2NC.js} +3 -3
  24. package/dist/cli.js +371 -143
  25. package/dist/{demo-server-32PU7BM6.js → demo-server-C7UGXXN7.js} +4 -4
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.js +6 -6
  28. package/dist/{intelligence-service-256DQBVR.js → intelligence-service-37TX4YV7.js} +2 -2
  29. package/dist/mcp.js +4 -4
  30. package/package.json +13 -13
  31. package/assets/assets/index-BJcjB5XW.js +0 -86
@@ -64,6 +64,7 @@ import {
64
64
  DiscoveryCompetitorTypes,
65
65
  DiscoveryPromoteCompetitorSkipReasons,
66
66
  DiscoverySessionStatuses,
67
+ FeatureNames,
67
68
  GA_SEARCH_LANDING_DEFAULT_LIMIT,
68
69
  GA_SEARCH_LANDING_DEFAULT_WINDOW,
69
70
  GA_SEARCH_LANDING_MAX_LIMIT,
@@ -118,11 +119,15 @@ import {
118
119
  OPERATIONAL_LOG_FIELDS_HEADER,
119
120
  OPERATIONAL_LOG_OPT_IN_CONTEXT_FIELDS,
120
121
  OUTSTANDING_PROVIDER_BATCH_STATUSES,
122
+ OutcomeReasonCodes,
123
+ OutcomeStatuses,
124
+ OutcomeTriggers,
121
125
  POOLED_RUN_ID_LIMIT,
122
126
  PROJECTS_WRITE_SCOPE,
123
127
  PROJECT_OVERVIEW_QUERY_CLASS_SCOPE,
124
128
  PROVIDER_ACCOUNT_FAILURE_STREAK,
125
129
  PROVIDER_ACCOUNT_RETRY_HOURS,
130
+ PROVIDER_NAMES,
126
131
  ProviderDispatchModes,
127
132
  READ_ONLY_SCOPE,
128
133
  RESEARCH_RUN_SCOPE,
@@ -264,6 +269,7 @@ import {
264
269
  brandLabelFromDomain,
265
270
  brandMetricsDtoSchema,
266
271
  breakdownShares,
272
+ bucketDuration,
267
273
  buildHarvestAnchorTerms,
268
274
  buildMeasurementExecutionIdentity,
269
275
  buildMeasurementRunManifestV1,
@@ -293,6 +299,7 @@ import {
293
299
  citationVisibilityResponseSchema,
294
300
  classifyAiReferralTrafficClass,
295
301
  classifyCitedSurface,
302
+ classifyOutcomeError,
296
303
  classifyProviderErrorMessage,
297
304
  classifySkillFile,
298
305
  classifySurfaceFromCategory,
@@ -610,6 +617,7 @@ import {
610
617
  nextScheduleUpdatedAt,
611
618
  noProvider,
612
619
  noQueries,
620
+ normalizeAgentSlug,
613
621
  normalizeCompetitorAliases,
614
622
  normalizeCompetitorDomain,
615
623
  normalizeIdTokens,
@@ -640,6 +648,7 @@ import {
640
648
  orderLocationsDefaultFirst,
641
649
  organicEvidenceDtoSchema,
642
650
  organicEvidencePeriodSchema,
651
+ outcomeSurfaceSchema,
643
652
  parseBoundedRate,
644
653
  parseInclusiveEndMs,
645
654
  parseMeasurementDraftEtagVersion,
@@ -855,7 +864,7 @@ import {
855
864
  wordpressSchemaDeployResultDtoSchema,
856
865
  wordpressSchemaStatusResultDtoSchema,
857
866
  wordpressStatusDtoSchema
858
- } from "./chunk-MRKV43TV.js";
867
+ } from "./chunk-IM2BFAAT.js";
859
868
 
860
869
  // src/intelligence-service.ts
861
870
  import { eq as eq89, desc as desc41, asc as asc18, and as and75, ne as ne16, or as or22, inArray as inArray36, gte as gte24, lte as lte20, isNull as isNull17, sql as sql32, exists as exists2 } from "drizzle-orm";
@@ -13118,12 +13127,28 @@ import { eq as eq5, ne, sql as sql5 } from "drizzle-orm";
13118
13127
  import { AsyncLocalStorage } from "async_hooks";
13119
13128
  var requestContext = new AsyncLocalStorage();
13120
13129
  var MAX_REQUEST_CONTEXT_LENGTH = 512;
13130
+ var ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]{1,39}$/;
13131
+ var MAX_ERROR_BODY_BYTES = 16384;
13132
+ function errorCodeOfBody(payload) {
13133
+ const text2 = typeof payload === "string" ? payload : Buffer.isBuffer(payload) ? payload.toString("utf8") : void 0;
13134
+ if (!text2 || text2.length > MAX_ERROR_BODY_BYTES || !text2.trimStart().startsWith("{")) return void 0;
13135
+ try {
13136
+ const code = JSON.parse(text2).error?.code;
13137
+ return typeof code === "string" && ERROR_CODE_PATTERN.test(code) ? code : void 0;
13138
+ } catch {
13139
+ return void 0;
13140
+ }
13141
+ }
13121
13142
  function getRequestContext(boundRequestId) {
13122
13143
  const context = requestContext.getStore();
13123
13144
  if (!context || context.completed && context.requestId !== boundRequestId) return void 0;
13124
13145
  const { completed: _completed, ...safeContext } = context;
13125
13146
  return safeContext;
13126
13147
  }
13148
+ function currentOutcomeAttribution() {
13149
+ const context = getRequestContext();
13150
+ return context ? { userAgent: context.userAgent, surfaceLabel: context.usageSurface, agentLabel: context.usageAgent } : void 0;
13151
+ }
13127
13152
  function requestAuditIdentity(request) {
13128
13153
  const principal = request.principal;
13129
13154
  if (!principal) return {};
@@ -13152,17 +13177,30 @@ function registerRequestContext(app, options = {}) {
13152
13177
  actorSession: sanitizeRequestContext(request.headers["x-canonry-actor-session"]) ?? void 0,
13153
13178
  method: request.method,
13154
13179
  // Route template only, never a raw URL, query string, or route parameter.
13155
- route: request.routeOptions.url
13180
+ route: request.routeOptions.url,
13181
+ usageSurface: headerValue(request.headers[USAGE_TELEMETRY_HEADERS.surface]),
13182
+ usageAgent: headerValue(request.headers[USAGE_TELEMETRY_HEADERS.agent])
13156
13183
  }, done);
13157
13184
  });
13158
13185
  app.addHook("preHandler", (request, _reply, done) => {
13159
13186
  populateIdentity(request);
13160
13187
  done();
13161
13188
  });
13162
- app.addHook("onError", (request, _reply, _error, done) => {
13189
+ app.addHook("onError", (request, _reply, error, done) => {
13163
13190
  populateIdentity(request);
13191
+ const code = error.code;
13192
+ const context = requestContext.getStore();
13193
+ if (context && typeof code === "string" && ERROR_CODE_PATTERN.test(code)) context.errorCode = code;
13164
13194
  done();
13165
13195
  });
13196
+ app.addHook("onSend", (_request, reply, payload, done) => {
13197
+ if (reply.statusCode >= 400) {
13198
+ const context = requestContext.getStore();
13199
+ const code = context ? errorCodeOfBody(payload) : void 0;
13200
+ if (context && code) context.errorCode = code;
13201
+ }
13202
+ done(null, payload);
13203
+ });
13166
13204
  app.addHook("onResponse", (request, reply, done) => {
13167
13205
  populateIdentity(request);
13168
13206
  const context = requestContext.getStore();
@@ -13176,6 +13214,7 @@ function registerRequestContext(app, options = {}) {
13176
13214
  route,
13177
13215
  statusCode: reply.statusCode,
13178
13216
  durationMs: reply.elapsedTime,
13217
+ ...context?.errorCode && reply.statusCode >= 400 ? { errorCode: context.errorCode } : {},
13179
13218
  userAgent: headerValue(request.headers["user-agent"]),
13180
13219
  actorSession: headerValue(request.headers["x-canonry-actor-session"]),
13181
13220
  principalKind: kind === "user" ? "user" : kind ? "api-key" : void 0,
@@ -14532,6 +14571,17 @@ async function sentimentRoutes(app, options) {
14532
14571
  });
14533
14572
  }
14534
14573
 
14574
+ // ../api-routes/src/outcome-telemetry.ts
14575
+ function createOutcomeEmitter(sink) {
14576
+ return (event) => {
14577
+ if (!sink) return;
14578
+ try {
14579
+ sink({ ...event, attribution: event.attribution ?? currentOutcomeAttribution() });
14580
+ } catch {
14581
+ }
14582
+ };
14583
+ }
14584
+
14535
14585
  // ../api-routes/src/oauth.ts
14536
14586
  import crypto6 from "crypto";
14537
14587
  import { and as and5, eq as eq10, isNull as isNull3, lt as lt3 } from "drizzle-orm";
@@ -14995,6 +15045,93 @@ function registerOAuthAdminRoutes(app, opts) {
14995
15045
  import crypto12 from "crypto";
14996
15046
  import { and as and13, eq as eq19, sql as sql7 } from "drizzle-orm";
14997
15047
 
15048
+ // ../api-routes/src/feature-outcome.ts
15049
+ function reportFeatureOutcome(app, target, outcome2) {
15050
+ try {
15051
+ app.emitOutcome({
15052
+ event: "feature.completed",
15053
+ properties: {
15054
+ feature: target.feature,
15055
+ operation: target.operation,
15056
+ ...target.trigger ? { trigger: target.trigger } : {},
15057
+ ...outcome2
15058
+ },
15059
+ ...target.attribution ? { attribution: target.attribution } : {}
15060
+ });
15061
+ } catch {
15062
+ }
15063
+ }
15064
+ function failedOutcome(err, reasonCode) {
15065
+ return { status: OutcomeStatuses.failed, ...classifyOutcomeError(err), ...reasonCode ? { reasonCode } : {} };
15066
+ }
15067
+ async function withFeatureOutcome(app, target, run, failureReason) {
15068
+ const startedAt = Date.now();
15069
+ let settled;
15070
+ const report = (outcome2) => {
15071
+ try {
15072
+ const resolved = outcome2();
15073
+ if (resolved) reportFeatureOutcome(app, target, { ...resolved, durationBucket: bucketDuration(Date.now() - startedAt) });
15074
+ } catch {
15075
+ }
15076
+ };
15077
+ let result;
15078
+ try {
15079
+ result = await run((outcome2) => {
15080
+ settled = outcome2;
15081
+ });
15082
+ } catch (err) {
15083
+ report(() => {
15084
+ if (settled === null) return null;
15085
+ if (settled && settled.status !== OutcomeStatuses.succeeded) return settled;
15086
+ return failedOutcome(err, failureReason?.(err));
15087
+ });
15088
+ throw err;
15089
+ }
15090
+ report(() => settled === void 0 ? { status: OutcomeStatuses.succeeded } : settled);
15091
+ return result;
15092
+ }
15093
+ function startRouteOutcome(app, feature, operation, options = {}) {
15094
+ const startedAt = Date.now();
15095
+ let sent = false;
15096
+ const report = (outcome2) => {
15097
+ if (sent) return;
15098
+ sent = true;
15099
+ const counts2 = Object.entries(outcome2.counts ?? {}).filter((entry) => typeof entry[1] === "number");
15100
+ reportFeatureOutcome(app, { feature, operation, ...options }, {
15101
+ status: outcome2.status,
15102
+ ...outcome2.reasonCode ? { reasonCode: outcome2.reasonCode } : {},
15103
+ ...outcome2.errorName ? { errorName: outcome2.errorName } : {},
15104
+ durationBucket: bucketDuration(Date.now() - startedAt),
15105
+ ...counts2.length > 0 ? { counts: Object.fromEntries(counts2) } : {}
15106
+ });
15107
+ };
15108
+ return {
15109
+ report,
15110
+ refuse(reasonCode, error) {
15111
+ report({ status: OutcomeStatuses.failed, reasonCode });
15112
+ return error;
15113
+ },
15114
+ guard(reasonCode, check2) {
15115
+ try {
15116
+ return check2();
15117
+ } catch (err) {
15118
+ report({ status: OutcomeStatuses.failed, ...classifyOutcomeError(err), reasonCode });
15119
+ throw err;
15120
+ }
15121
+ }
15122
+ };
15123
+ }
15124
+ function upstreamFailure(err) {
15125
+ const classified = classifyOutcomeError(err);
15126
+ const upstreamStatus = err instanceof AppError ? err.details?.upstreamStatus : void 0;
15127
+ return typeof upstreamStatus === "number" ? { ...classified, reasonCode: classifyOutcomeError({ status: upstreamStatus }).reasonCode } : classified;
15128
+ }
15129
+ function credentialFailure(err) {
15130
+ const classified = classifyOutcomeError(err);
15131
+ const transient = [OutcomeReasonCodes.RATE_LIMITED, OutcomeReasonCodes.TIMEOUT, OutcomeReasonCodes.NETWORK];
15132
+ return transient.includes(classified.reasonCode) ? classified : { ...classified, reasonCode: OutcomeReasonCodes.INVALID_CREDENTIALS };
15133
+ }
15134
+
14998
15135
  // ../api-routes/src/competitor-writes.ts
14999
15136
  import crypto8 from "crypto";
15000
15137
  import { and as and8, eq as eq13 } from "drizzle-orm";
@@ -15754,6 +15891,450 @@ function marketCompetitorDto(entry, stored, now) {
15754
15891
  };
15755
15892
  }
15756
15893
 
15894
+ // ../api-routes/src/notifications/destinations.ts
15895
+ import { z as z2 } from "zod";
15896
+
15897
+ // ../api-routes/src/notifications/alert.ts
15898
+ import { z } from "zod";
15899
+ var alertSeveritySchema = z.enum(["critical", "warning", "success", "info"]);
15900
+ var AlertSeverities = alertSeveritySchema.enum;
15901
+ var AlertFieldLabels = {
15902
+ status: "Status",
15903
+ check: "Check",
15904
+ previously: "Was",
15905
+ remediation: "What to do",
15906
+ alsoFailing: "Also failing",
15907
+ event: "Event",
15908
+ run: "Run",
15909
+ changes: "Changes",
15910
+ insights: "Insights",
15911
+ location: "Location",
15912
+ rating: "Rating",
15913
+ reviewer: "Reviewer",
15914
+ replied: "Replied",
15915
+ source: "Source"
15916
+ };
15917
+ var HEALTH_EVENTS = /* @__PURE__ */ new Set(["health.degraded", "health.recovered"]);
15918
+ function isHealth(payload) {
15919
+ return HEALTH_EVENTS.has(payload.event);
15920
+ }
15921
+ var MAX_LIST_ITEMS = 10;
15922
+ function bulletList(lines) {
15923
+ const shown = lines.slice(0, MAX_LIST_ITEMS);
15924
+ const omitted = lines.length - shown.length;
15925
+ const body = shown.map((line) => `\u2022 ${line}`).join("\n");
15926
+ return omitted > 0 ? `${body}
15927
+ \u2022 \u2026and ${omitted} more` : body;
15928
+ }
15929
+ function healthView(payload) {
15930
+ const { health, project } = payload;
15931
+ const recovered = payload.event === "health.recovered";
15932
+ const severity = recovered ? AlertSeverities.success : health.status === "fail" ? AlertSeverities.critical : AlertSeverities.warning;
15933
+ const fields = [
15934
+ { label: AlertFieldLabels.status, value: recovered ? "recovered" : health.status, compact: true },
15935
+ { label: AlertFieldLabels.check, value: health.code, compact: true }
15936
+ ];
15937
+ if (health.previousStatus) {
15938
+ fields.push({ label: AlertFieldLabels.previously, value: health.previousStatus, compact: true });
15939
+ }
15940
+ if (health.remediation) {
15941
+ fields.push({ label: AlertFieldLabels.remediation, value: health.remediation });
15942
+ }
15943
+ const others = health.failing.filter((check2) => check2.code !== health.code);
15944
+ if (others.length > 0) {
15945
+ fields.push({
15946
+ label: `${AlertFieldLabels.alsoFailing} (${others.length})`,
15947
+ value: bulletList(others.map((check2) => `${check2.status} \u2014 ${check2.code}`))
15948
+ });
15949
+ }
15950
+ const website = health.code.startsWith("site.reachability.");
15951
+ return {
15952
+ severity,
15953
+ title: website ? recovered ? `${project.name} website is back up` : `${project.name} website is down` : recovered ? `${project.name} recovered` : `${project.name} \u2014 measurement degraded`,
15954
+ body: health.summary,
15955
+ fields,
15956
+ url: payload.dashboardUrl,
15957
+ footer: project.canonicalDomain,
15958
+ timestamp: health.checkedAt
15959
+ };
15960
+ }
15961
+ function runView(payload) {
15962
+ const failed = payload.event === "run.failed";
15963
+ const transitions = "transitions" in payload ? payload.transitions : [];
15964
+ const insights2 = "insights" in payload ? payload.insights : [];
15965
+ const fields = [
15966
+ { label: AlertFieldLabels.event, value: payload.event, compact: true },
15967
+ { label: AlertFieldLabels.run, value: payload.run.status, compact: true }
15968
+ ];
15969
+ if (transitions.length > 0) {
15970
+ fields.push({
15971
+ label: `${AlertFieldLabels.changes} (${transitions.length})`,
15972
+ value: bulletList(transitions.map((t) => `${t.provider}: ${t.from} \u2192 ${t.to} \u2014 ${t.query}`))
15973
+ });
15974
+ }
15975
+ if (insights2.length > 0) {
15976
+ fields.push({
15977
+ label: `${AlertFieldLabels.insights} (${insights2.length})`,
15978
+ value: bulletList(insights2.map((i) => `[${i.severity}] ${i.title}`))
15979
+ });
15980
+ }
15981
+ return {
15982
+ severity: failed ? AlertSeverities.critical : AlertSeverities.info,
15983
+ title: payload.project.name,
15984
+ fields,
15985
+ url: payload.dashboardUrl,
15986
+ footer: payload.project.canonicalDomain,
15987
+ ...payload.run.finishedAt ? { timestamp: payload.run.finishedAt } : {}
15988
+ };
15989
+ }
15990
+ function stars(rating) {
15991
+ const filled = Math.max(0, Math.min(5, Math.round(rating)));
15992
+ return `${"\u2605".repeat(filled)}${"\u2606".repeat(5 - filled)}`;
15993
+ }
15994
+ function snippet(text2, max2 = 160) {
15995
+ const flat = text2.replace(/\s+/g, " ").trim();
15996
+ return flat.length > max2 ? `${flat.slice(0, max2 - 1)}\u2026` : flat;
15997
+ }
15998
+ var SOURCE_LABELS = {
15999
+ gbp: "Business Profile",
16000
+ places: "Public listing (Places, partial)"
16001
+ };
16002
+ function reviewView(payload) {
16003
+ const { project, reviews } = payload;
16004
+ const newest = reviews.reduce((latest, r) => !latest || r.updateTime > latest ? r.updateTime : latest, void 0);
16005
+ const origins = [...new Set(reviews.map((r) => r.origin))];
16006
+ const sourceField = {
16007
+ label: AlertFieldLabels.source,
16008
+ value: origins.map((origin) => SOURCE_LABELS[origin]).join(", "),
16009
+ compact: true
16010
+ };
16011
+ if (reviews.length === 1) {
16012
+ const review = reviews[0];
16013
+ const fields = [
16014
+ { label: AlertFieldLabels.location, value: review.location.displayName, compact: true },
16015
+ { label: AlertFieldLabels.rating, value: `${stars(review.starRating)} (${review.starRating}/5)`, compact: true }
16016
+ ];
16017
+ if (review.reviewerName) fields.push({ label: AlertFieldLabels.reviewer, value: review.reviewerName, compact: true });
16018
+ if (review.replied !== null) fields.push({ label: AlertFieldLabels.replied, value: review.replied ? "yes" : "no", compact: true });
16019
+ fields.push(sourceField);
16020
+ return {
16021
+ severity: AlertSeverities.warning,
16022
+ title: `${project.name}: new ${review.starRating}-star review`,
16023
+ body: review.comment ?? "Rating only, no text.",
16024
+ fields,
16025
+ url: review.reviewUri ?? review.location.mapsUri ?? payload.dashboardUrl,
16026
+ footer: project.canonicalDomain,
16027
+ timestamp: review.updateTime
16028
+ };
16029
+ }
16030
+ return {
16031
+ severity: AlertSeverities.warning,
16032
+ title: `${project.name}: ${reviews.length} new negative reviews`,
16033
+ body: bulletList(reviews.map((r) => {
16034
+ const text2 = r.comment ? ` "${snippet(r.comment)}"` : "";
16035
+ const by = r.reviewerName ? ` (${r.reviewerName})` : "";
16036
+ return `${stars(r.starRating)} ${r.location.displayName}:${text2}${by}`;
16037
+ })),
16038
+ fields: [sourceField],
16039
+ url: payload.dashboardUrl,
16040
+ footer: project.canonicalDomain,
16041
+ ...newest ? { timestamp: newest } : {}
16042
+ };
16043
+ }
16044
+ function ratingView(payload) {
16045
+ const { project, ratings } = payload;
16046
+ const line = (r) => {
16047
+ const counts2 = r.previousReviewCount !== null && r.reviewCount !== null ? ` (${r.previousReviewCount} \u2192 ${r.reviewCount} reviews)` : "";
16048
+ return `${r.location.displayName}: ${r.previousRating} \u2192 ${r.rating}${counts2}`;
16049
+ };
16050
+ const single = ratings.length === 1 ? ratings[0] : null;
16051
+ return {
16052
+ severity: AlertSeverities.warning,
16053
+ title: single ? `${project.name}: Google rating fell to ${single.rating}` : `${project.name}: Google rating fell at ${ratings.length} locations`,
16054
+ body: single ? line(single) : bulletList(ratings.map(line)),
16055
+ fields: [
16056
+ { label: AlertFieldLabels.source, value: SOURCE_LABELS.places, compact: true },
16057
+ {
16058
+ label: AlertFieldLabels.remediation,
16059
+ value: "Read the newest reviews on Google Maps. Without Business Profile review access, Canonry sees at most five of them."
16060
+ }
16061
+ ],
16062
+ url: single?.location.mapsUri ?? payload.dashboardUrl,
16063
+ footer: project.canonicalDomain,
16064
+ timestamp: ratings.reduce((latest, r) => r.observedAt > latest ? r.observedAt : latest, ratings[0]?.observedAt ?? "")
16065
+ };
16066
+ }
16067
+ function toAlertView(payload) {
16068
+ if (isHealth(payload)) return healthView(payload);
16069
+ if (payload.event === "review.negative") return reviewView(payload);
16070
+ if (payload.event === "review.rating-dropped") return ratingView(payload);
16071
+ return runView(payload);
16072
+ }
16073
+
16074
+ // ../api-routes/src/notifications/destinations.ts
16075
+ var webhookDestinationSchema = z2.enum(["first-party", "discord", "slack"]);
16076
+ var WebhookDestinations = webhookDestinationSchema.enum;
16077
+ function hostIs(url, ...domains) {
16078
+ const host = url.hostname.toLowerCase();
16079
+ return domains.some((domain) => host === domain || host.endsWith(`.${domain}`));
16080
+ }
16081
+ var DISCORD_MAX_DESCRIPTION = 3800;
16082
+ var DISCORD_MAX_FIELD = 1e3;
16083
+ var DISCORD_COLORS = {
16084
+ [AlertSeverities.critical]: 14235195,
16085
+ [AlertSeverities.warning]: 14256926,
16086
+ [AlertSeverities.success]: 3055183,
16087
+ [AlertSeverities.info]: 4873068
16088
+ };
16089
+ var SEVERITY_ICONS = {
16090
+ [AlertSeverities.critical]: "\u{1F534}",
16091
+ [AlertSeverities.warning]: "\u{1F7E0}",
16092
+ [AlertSeverities.success]: "\u2705",
16093
+ [AlertSeverities.info]: "\u{1F4CA}"
16094
+ };
16095
+ function clamp(value, max2) {
16096
+ return value.length <= max2 ? value : `${value.slice(0, max2 - 1)}\u2026`;
16097
+ }
16098
+ function absoluteLink(url) {
16099
+ if (!url) return void 0;
16100
+ try {
16101
+ const parsed = new URL(url);
16102
+ return parsed.protocol === "https:" || parsed.protocol === "http:" ? url : void 0;
16103
+ } catch {
16104
+ return void 0;
16105
+ }
16106
+ }
16107
+ function renderDiscord(view) {
16108
+ return {
16109
+ username: "Canonry",
16110
+ embeds: [{
16111
+ title: `${SEVERITY_ICONS[view.severity]} ${view.title}`,
16112
+ ...view.body ? { description: clamp(view.body, DISCORD_MAX_DESCRIPTION) } : {},
16113
+ color: DISCORD_COLORS[view.severity],
16114
+ fields: view.fields.map((field) => ({
16115
+ name: field.label,
16116
+ value: clamp(field.value, DISCORD_MAX_FIELD),
16117
+ ...field.compact ? { inline: true } : {}
16118
+ })),
16119
+ ...absoluteLink(view.url) ? { url: absoluteLink(view.url) } : {},
16120
+ ...view.timestamp ? { timestamp: view.timestamp } : {},
16121
+ ...view.footer ? { footer: { text: view.footer } } : {}
16122
+ }]
16123
+ };
16124
+ }
16125
+ var SLACK_MAX_TEXT = 2900;
16126
+ var SLACK_MAX_FIELD = 1e3;
16127
+ var SLACK_COLORS = {
16128
+ [AlertSeverities.critical]: "#d9363b",
16129
+ [AlertSeverities.warning]: "#d98b1e",
16130
+ [AlertSeverities.success]: "#2e9e4f",
16131
+ [AlertSeverities.info]: "#4a5b6c"
16132
+ };
16133
+ function renderSlack(view) {
16134
+ const headline = `${SEVERITY_ICONS[view.severity]} ${view.title}`;
16135
+ return {
16136
+ // `text` is the notification preview and the accessible fallback, so it
16137
+ // must stand alone rather than repeat an empty string.
16138
+ text: headline,
16139
+ attachments: [{
16140
+ color: SLACK_COLORS[view.severity],
16141
+ title: headline,
16142
+ ...absoluteLink(view.url) ? { title_link: absoluteLink(view.url) } : {},
16143
+ ...view.body ? { text: clamp(view.body, SLACK_MAX_TEXT) } : {},
16144
+ fields: view.fields.map((field) => ({
16145
+ title: field.label,
16146
+ value: clamp(field.value, SLACK_MAX_FIELD),
16147
+ ...field.compact ? { short: true } : {}
16148
+ })),
16149
+ ...view.footer ? { footer: view.footer } : {},
16150
+ ...view.timestamp ? { ts: Math.floor(new Date(view.timestamp).getTime() / 1e3) } : {}
16151
+ }]
16152
+ };
16153
+ }
16154
+ var DISCORD_ADAPTER = {
16155
+ destination: WebhookDestinations.discord,
16156
+ matches: (url) => hostIs(url, "discord.com", "discordapp.com") && url.pathname.startsWith("/api/webhooks/"),
16157
+ render: renderDiscord,
16158
+ signed: false
16159
+ };
16160
+ var SLACK_ADAPTER = {
16161
+ destination: WebhookDestinations.slack,
16162
+ matches: (url) => hostIs(url, "slack.com") && url.pathname.startsWith("/services/"),
16163
+ render: renderSlack,
16164
+ signed: false
16165
+ };
16166
+ var FIRST_PARTY_ADAPTER = {
16167
+ destination: WebhookDestinations["first-party"],
16168
+ matches: () => true,
16169
+ signed: true
16170
+ };
16171
+ var DESTINATION_ADAPTERS = [
16172
+ DISCORD_ADAPTER,
16173
+ SLACK_ADAPTER,
16174
+ FIRST_PARTY_ADAPTER
16175
+ ];
16176
+ function resolveDestination(rawUrl) {
16177
+ let url;
16178
+ try {
16179
+ url = new URL(rawUrl);
16180
+ } catch {
16181
+ return FIRST_PARTY_ADAPTER;
16182
+ }
16183
+ if (url.protocol !== "https:") return FIRST_PARTY_ADAPTER;
16184
+ return DESTINATION_ADAPTERS.find((adapter) => adapter.matches(url)) ?? FIRST_PARTY_ADAPTER;
16185
+ }
16186
+
16187
+ // ../api-routes/src/connection-telemetry.ts
16188
+ var PROVIDER_NAME_SET = new Set(PROVIDER_NAMES);
16189
+ function outcomeProviderName(name) {
16190
+ return PROVIDER_NAME_SET.has(name) ? name : void 0;
16191
+ }
16192
+ function telemetryRead(read, fallback) {
16193
+ try {
16194
+ return read();
16195
+ } catch {
16196
+ return fallback;
16197
+ }
16198
+ }
16199
+ var APP_ERROR_REASONS = {
16200
+ NOT_IMPLEMENTED: "UNSUPPORTED",
16201
+ INTERNAL_ERROR: "INTERNAL",
16202
+ MISSING_DEPENDENCY: "UNSUPPORTED"
16203
+ };
16204
+ function routeOutcomeFailure(err, reasonCode) {
16205
+ const classified = classifyOutcomeError(err);
16206
+ if (reasonCode) return { ...classified, reasonCode };
16207
+ const { code, details } = err && typeof err === "object" ? err : {};
16208
+ if (code === "FORBIDDEN") {
16209
+ const upstreamStatus = details?.upstreamStatus;
16210
+ return {
16211
+ ...classified,
16212
+ reasonCode: typeof upstreamStatus === "number" ? classifyOutcomeError({ status: upstreamStatus }).reasonCode : "GATE_REFUSED"
16213
+ };
16214
+ }
16215
+ const mapped = typeof code === "string" ? APP_ERROR_REASONS[code] : void 0;
16216
+ return { ...classified, reasonCode: mapped ?? classified.reasonCode };
16217
+ }
16218
+ var DESTINATION_TARGETS = {
16219
+ "first-party": "first_party",
16220
+ discord: "discord",
16221
+ slack: "slack"
16222
+ };
16223
+ function webhookOutcomeTarget(rawUrl) {
16224
+ return DESTINATION_TARGETS[resolveDestination(rawUrl).destination];
16225
+ }
16226
+ function webhookTargetRefusalReason(check2) {
16227
+ if (check2.blocked) return "BLOCKED_UNSAFE_URL";
16228
+ if (check2.unresolved) return "NETWORK";
16229
+ return "VALIDATION";
16230
+ }
16231
+ function webhookResponseReason(result) {
16232
+ if (result.status >= 200 && result.status < 300) return void 0;
16233
+ if (result.status === 0) return result.timedOut ? "TIMEOUT" : "NETWORK";
16234
+ if (result.status >= 500) return "HTTP_5XX";
16235
+ if (result.status >= 400) return "HTTP_4XX";
16236
+ return "UNSUPPORTED";
16237
+ }
16238
+ function oauthStartAttribution() {
16239
+ const context = getRequestContext();
16240
+ const surface = outcomeSurfaceSchema.safeParse(context?.usageSurface);
16241
+ const agent = normalizeAgentSlug(context?.usageAgent);
16242
+ return {
16243
+ ...surface.success ? { surface: surface.data } : {},
16244
+ ...agent ? { agent } : {}
16245
+ };
16246
+ }
16247
+ function oauthStateAttribution(state) {
16248
+ if (!state) return {};
16249
+ const surface = outcomeSurfaceSchema.safeParse(state.surface);
16250
+ const agent = typeof state.agent === "string" ? normalizeAgentSlug(state.agent) : null;
16251
+ return {
16252
+ ...surface.success ? { surface: surface.data } : {},
16253
+ ...agent ? { agent } : {}
16254
+ };
16255
+ }
16256
+ function unverifiedOAuthStateField(encoded, field) {
16257
+ if (!encoded || encoded.length > 8192) return void 0;
16258
+ try {
16259
+ const envelope = JSON.parse(Buffer.from(encoded, "base64url").toString());
16260
+ if (typeof envelope.payload !== "string") return void 0;
16261
+ const value = JSON.parse(envelope.payload)[field];
16262
+ return typeof value === "string" ? value : void 0;
16263
+ } catch {
16264
+ return void 0;
16265
+ }
16266
+ }
16267
+ var NO_ATTEMPT = {
16268
+ update: () => {
16269
+ },
16270
+ started: () => {
16271
+ },
16272
+ succeeded: () => {
16273
+ },
16274
+ failed: () => {
16275
+ },
16276
+ cancelled: () => {
16277
+ },
16278
+ deferred: () => {
16279
+ }
16280
+ };
16281
+ function startConnectionAttempt(app, subject, now = Date.now) {
16282
+ if (!subject) return NO_ATTEMPT;
16283
+ const startedAt = now();
16284
+ let current = { ...subject };
16285
+ let reported = false;
16286
+ const report = (status, failure) => {
16287
+ if (reported) return;
16288
+ reported = true;
16289
+ try {
16290
+ const emit = app.emitOutcome;
16291
+ if (typeof emit !== "function") return;
16292
+ const properties = {
16293
+ ...current,
16294
+ status,
16295
+ ...failure,
16296
+ ...status === "started" ? {} : { durationBucket: bucketDuration(now() - startedAt) }
16297
+ };
16298
+ for (const key of Object.keys(properties)) {
16299
+ if (properties[key] === void 0) delete properties[key];
16300
+ }
16301
+ emit.call(app, { event: "integration.connection", properties });
16302
+ } catch {
16303
+ }
16304
+ };
16305
+ return {
16306
+ update: (patch) => {
16307
+ current = { ...current, ...patch };
16308
+ },
16309
+ started: () => report("started"),
16310
+ succeeded: () => report("succeeded"),
16311
+ failed: (err, reasonCode) => report("failed", routeOutcomeFailure(err, reasonCode)),
16312
+ cancelled: (reasonCode) => report("cancelled", { reasonCode }),
16313
+ deferred: () => {
16314
+ reported = true;
16315
+ }
16316
+ };
16317
+ }
16318
+ function connectionRoute(app, subjectOf, handler) {
16319
+ return async (request, reply) => {
16320
+ let subject;
16321
+ try {
16322
+ subject = subjectOf(request);
16323
+ } catch {
16324
+ subject = void 0;
16325
+ }
16326
+ const attempt = startConnectionAttempt(app, subject);
16327
+ try {
16328
+ const result = await handler(request, reply, attempt);
16329
+ attempt.succeeded();
16330
+ return result;
16331
+ } catch (err) {
16332
+ attempt.failed(err);
16333
+ throw err;
16334
+ }
16335
+ };
16336
+ }
16337
+
15757
16338
  // ../api-routes/src/settings.ts
15758
16339
  var SETTINGS_WRITE_SCOPE = "settings.write";
15759
16340
  async function settingsRoutes(app, opts) {
@@ -15782,7 +16363,13 @@ async function settingsRoutes(app, opts) {
15782
16363
  bing: opts.bing ?? { configured: false }
15783
16364
  };
15784
16365
  });
15785
- app.put("/settings/providers/:name", async (request) => {
16366
+ app.put("/settings/providers/:name", connectionRoute(app, (request) => {
16367
+ const provider = outcomeProviderName(request.params.name);
16368
+ const body = request.body;
16369
+ if (!provider || body?.apiKey === void 0 && body?.baseUrl === void 0) return void 0;
16370
+ const configured = (opts.providerSummary ?? []).some((entry) => entry.name === provider && entry.configured);
16371
+ return { integration: "provider", provider, action: configured ? "reauth" : "connect" };
16372
+ }, async (request) => {
15786
16373
  requireScope(request, SETTINGS_WRITE_SCOPE);
15787
16374
  const { apiKey, baseUrl, model, quota } = request.body ?? {};
15788
16375
  const name = request.params.name;
@@ -15862,8 +16449,8 @@ async function settingsRoutes(app, opts) {
15862
16449
  throw internalError("Failed to update provider configuration");
15863
16450
  }
15864
16451
  return result;
15865
- });
15866
- app.post("/settings/providers/reload", async (request) => {
16452
+ }));
16453
+ app.post("/settings/providers/reload", async (request) => withFeatureOutcome(app, { feature: "providers", operation: "reload" }, async (settle) => {
15867
16454
  requireScope(request, SETTINGS_WRITE_SCOPE);
15868
16455
  requireInstanceAdministrator(request);
15869
16456
  const parsed = providerReloadRequestSchema.safeParse(request.body ?? {});
@@ -15879,8 +16466,9 @@ async function settingsRoutes(app, opts) {
15879
16466
  entityType: "provider"
15880
16467
  });
15881
16468
  const providers = await opts.onProviderReload(parsed.data, { actor, userAgent, actorSession, requestId, credentialId });
16469
+ settle({ status: OutcomeStatuses.succeeded, counts: { providers: providers.filter((provider) => provider.configured).length } });
15882
16470
  return providerReloadResponseDtoSchema.parse({ reloaded: true, providers });
15883
- });
16471
+ }, (err) => err instanceof AppError && err.code === "NOT_IMPLEMENTED" ? OutcomeReasonCodes.UNSUPPORTED : void 0));
15884
16472
  app.put("/settings/google", async (request) => {
15885
16473
  requireScope(request, SETTINGS_WRITE_SCOPE);
15886
16474
  const { clientId, clientSecret } = request.body ?? {};
@@ -18386,7 +18974,7 @@ async function projectRoutes(app, opts) {
18386
18974
  });
18387
18975
  return reply.send({ defaultLocation: label });
18388
18976
  });
18389
- app.get("/projects/:name/export", async (request, reply) => {
18977
+ app.get("/projects/:name/export", async (request, reply) => withFeatureOutcome(app, { feature: "exports", operation: "export" }, async (settle) => {
18390
18978
  const project = resolveProject(app.db, request.params.name);
18391
18979
  const negativeReviewMaxStars = readNegativeReviewMaxStars(app.db, project.id);
18392
18980
  const qs = app.db.select().from(queries).where(eq19(queries.projectId, project.id)).all();
@@ -18450,8 +19038,9 @@ async function projectRoutes(app, opts) {
18450
19038
  } : {}
18451
19039
  }
18452
19040
  };
19041
+ settle({ status: OutcomeStatuses.succeeded, counts: { queries: qs.length } });
18453
19042
  return reply.send(config);
18454
- });
19043
+ }));
18455
19044
  }
18456
19045
  function assertProviderModelScope(request, current, next, nextProviders) {
18457
19046
  const effectiveCurrent = pruneProviderModelsForProviders(current, nextProviders);
@@ -25707,9 +26296,12 @@ async function measurementDraftRoutes(app, opts = {}) {
25707
26296
  diff: diffCompiledPlans(comparable, compiled.plan, active?.revision ?? null)
25708
26297
  };
25709
26298
  });
25710
- app.post("/projects/:name/measurement-plan/draft/actions/publish", async (request, reply) => {
26299
+ app.post("/projects/:name/measurement-plan/draft/actions/publish", async (request, reply) => withFeatureOutcome(app, { feature: "measurement", operation: "publish" }, async (recordOutcome) => {
25711
26300
  const gate = beginMutation(request, reply, "publish");
25712
- if (gate.replay !== null) return gate.replay;
26301
+ if (gate.replay !== null) {
26302
+ recordOutcome(null);
26303
+ return gate.replay;
26304
+ }
25713
26305
  const parsed = measurementDraftPublishRequestSchema.safeParse(request.body);
25714
26306
  if (!parsed.success) throw validationError('Invalid "publish" payload', { issues: parsed.error.issues });
25715
26307
  const ifMatch = requireIfMatch(request);
@@ -25718,7 +26310,7 @@ async function measurementDraftRoutes(app, opts = {}) {
25718
26310
  let pinEffect = null;
25719
26311
  const settled = app.db.transaction((tx) => {
25720
26312
  const pinWrite = beginMarketPinWrite(tx, gate.project);
25721
- const settle = (published, version, plan) => {
26313
+ const settle = (published, version, plan2) => {
25722
26314
  const response = {
25723
26315
  published,
25724
26316
  active: {
@@ -25726,7 +26318,7 @@ async function measurementDraftRoutes(app, opts = {}) {
25726
26318
  checksum: version.checksum,
25727
26319
  compiledChecksum: version.compiledChecksum,
25728
26320
  createdAt: version.createdAt,
25729
- plan
26321
+ plan: plan2
25730
26322
  }
25731
26323
  };
25732
26324
  sweepExpiredMeasurementReceipts(tx, now);
@@ -25843,9 +26435,11 @@ async function measurementDraftRoutes(app, opts = {}) {
25843
26435
  const created = tx.select().from(measurementPlanVersions).where(eq31(measurementPlanVersions.id, versionId)).get();
25844
26436
  return settle(true, created, compiled.plan);
25845
26437
  });
26438
+ const { plan } = settled.active;
26439
+ recordOutcome(settled.published ? { status: OutcomeStatuses.succeeded, counts: { items: plan.groups.length, targets: plan.targets.length, queries: plan.querySnapshots.length } } : { status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.NO_DATA });
25846
26440
  notifyMarketPinWrite(opts, gate.project, pinEffect);
25847
26441
  return settled;
25848
- });
26442
+ }));
25849
26443
  app.post("/projects/:name/measurement-plan/draft/actions/discard", async (request, reply) => {
25850
26444
  const gate = beginMutation(request, reply, "discard");
25851
26445
  if (gate.replay !== null) return gate.replay;
@@ -31773,7 +32367,7 @@ async function resolveWebhookTarget(raw, options = {}) {
31773
32367
  }
31774
32368
  const blocked = addresses.find((entry) => isBlockedAddress(entry.address, options));
31775
32369
  if (blocked) {
31776
- return { ok: false, message: '"url" must not resolve to a private or loopback address' };
32370
+ return { ok: false, message: '"url" must not resolve to a private or loopback address', blocked: true };
31777
32371
  }
31778
32372
  return {
31779
32373
  ok: true,
@@ -31825,11 +32419,13 @@ async function deliverWebhook(target, payload, webhookSecret) {
31825
32419
  resolve({ status: response.statusCode ?? 0, error: null });
31826
32420
  });
31827
32421
  });
32422
+ let timedOut = false;
31828
32423
  request.on("timeout", () => {
32424
+ timedOut = true;
31829
32425
  request.destroy(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`));
31830
32426
  });
31831
32427
  request.on("error", (error) => {
31832
- resolve({ status: 0, error: error.message });
32428
+ resolve({ status: 0, error: error.message, ...timedOut ? { timedOut: true } : {} });
31833
32429
  });
31834
32430
  request.end(body);
31835
32431
  });
@@ -36537,7 +37133,7 @@ function filenameFor(projectName, format2) {
36537
37133
  return `canonry-results-${safeName}-${date}.${format2}`;
36538
37134
  }
36539
37135
  async function resultsExportRoutes(app) {
36540
- app.get("/projects/:name/results/export", async (request, reply) => {
37136
+ app.get("/projects/:name/results/export", async (request, reply) => withFeatureOutcome(app, { feature: "exports", operation: "export" }, async (settle) => {
36541
37137
  const project = resolveProject(app.db, request.params.name);
36542
37138
  const format2 = parseFormat(request.query.format);
36543
37139
  const includeProbes = parseBoolean(request.query.includeProbes, "includeProbes");
@@ -36634,13 +37230,11 @@ async function resultsExportRoutes(app) {
36634
37230
  reply.header("Content-Disposition", `attachment; filename="${filenameFor(project.name, format2)}"`);
36635
37231
  reply.header("Cache-Control", "private, no-store");
36636
37232
  reply.header("X-Content-Type-Options", "nosniff");
36637
- if (format2 === "csv") {
36638
- reply.type("text/csv; charset=utf-8");
36639
- return reply.send(renderResultsExportCsv(dto));
36640
- }
36641
- reply.type("application/json; charset=utf-8");
36642
- return reply.send(dto);
36643
- });
37233
+ const body = format2 === "csv" ? renderResultsExportCsv(dto) : JSON.stringify(dto);
37234
+ settle({ status: OutcomeStatuses.succeeded, counts: { rows: records.length, bytes: Buffer.byteLength(body) } });
37235
+ reply.type(format2 === "csv" ? "text/csv; charset=utf-8" : "application/json; charset=utf-8");
37236
+ return reply.send(body);
37237
+ }));
36644
37238
  }
36645
37239
 
36646
37240
  // ../api-routes/src/composites.ts
@@ -38135,6 +38729,16 @@ function findRecommendationByRef(db, project, targetRef) {
38135
38729
  const rows = buildContentTargetRows(input);
38136
38730
  return rows.find((r) => r.targetRef === targetRef) ?? null;
38137
38731
  }
38732
+ function contentFailureReason(err) {
38733
+ return err instanceof AppError && err.code === "PROVIDER_ERROR" ? OutcomeReasonCodes.NOT_CONNECTED : void 0;
38734
+ }
38735
+ function generatedCounts(unit, costMillicents) {
38736
+ return {
38737
+ [unit]: 1,
38738
+ modelCalls: 1,
38739
+ ...Number.isInteger(costMillicents) && costMillicents >= 0 ? { costMicros: costMillicents * 10 } : {}
38740
+ };
38741
+ }
38138
38742
  async function contentRoutes(app, opts = {}) {
38139
38743
  app.get("/projects/:name/content/targets", async (request) => {
38140
38744
  const project = resolveProject(app.db, request.params.name);
@@ -38251,7 +38855,7 @@ async function contentRoutes(app, opts = {}) {
38251
38855
  }
38252
38856
  return reply.send(formatExplanationRow(row));
38253
38857
  });
38254
- app.post("/projects/:name/content/recommendations/:targetRef/analyze", async (request, reply) => {
38858
+ app.post("/projects/:name/content/recommendations/:targetRef/analyze", async (request, reply) => withFeatureOutcome(app, { feature: "content", operation: "analyze" }, async (settle) => {
38255
38859
  const project = resolveProject(app.db, request.params.name);
38256
38860
  const explainer = opts.explainContentRecommendation;
38257
38861
  if (!explainer) {
@@ -38274,7 +38878,10 @@ async function contentRoutes(app, opts = {}) {
38274
38878
  eq52(recommendationExplanations.projectId, project.id),
38275
38879
  eq52(recommendationExplanations.targetRef, targetRef)
38276
38880
  )).orderBy(desc26(recommendationExplanations.generatedAt)).limit(1).get();
38277
- if (cached) return reply.send(formatExplanationRow(cached));
38881
+ if (cached) {
38882
+ settle({ status: OutcomeStatuses.succeeded, counts: { targets: 1 } });
38883
+ return reply.send(formatExplanationRow(cached));
38884
+ }
38278
38885
  }
38279
38886
  const result = await explainer({
38280
38887
  projectId: project.id,
@@ -38309,6 +38916,7 @@ async function contentRoutes(app, opts = {}) {
38309
38916
  generatedAt: now
38310
38917
  }
38311
38918
  }).run();
38919
+ settle({ status: OutcomeStatuses.succeeded, counts: generatedCounts("targets", result.costMillicents) });
38312
38920
  const row = app.db.select().from(recommendationExplanations).where(and45(
38313
38921
  eq52(recommendationExplanations.projectId, project.id),
38314
38922
  eq52(recommendationExplanations.targetRef, targetRef),
@@ -38316,7 +38924,7 @@ async function contentRoutes(app, opts = {}) {
38316
38924
  )).get();
38317
38925
  if (!row) throw notFound("recommendationExplanation", targetRef);
38318
38926
  return reply.send(formatExplanationRow(row));
38319
- });
38927
+ }, contentFailureReason));
38320
38928
  app.get("/projects/:name/content/recommendations/:targetRef/brief", async (request, reply) => {
38321
38929
  const project = resolveProject(app.db, request.params.name);
38322
38930
  const { targetRef } = request.params;
@@ -38333,7 +38941,7 @@ async function contentRoutes(app, opts = {}) {
38333
38941
  if (!row) throw notFound("recommendationBrief", request.params.targetRef);
38334
38942
  return reply.send(formatBriefRow(row));
38335
38943
  });
38336
- app.post("/projects/:name/content/recommendations/:targetRef/brief", async (request, reply) => {
38944
+ app.post("/projects/:name/content/recommendations/:targetRef/brief", async (request, reply) => withFeatureOutcome(app, { feature: "content", operation: "brief" }, async (settle) => {
38337
38945
  const project = resolveProject(app.db, request.params.name);
38338
38946
  const synthesizer = opts.briefContentRecommendation;
38339
38947
  if (!synthesizer) {
@@ -38352,13 +38960,17 @@ async function contentRoutes(app, opts = {}) {
38352
38960
  throw notFound("contentRecommendation", targetRef);
38353
38961
  }
38354
38962
  if (recommendation.winnabilityClass === WinnabilityClasses.ceded) {
38963
+ settle({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.GATE_REFUSED });
38355
38964
  throw validationError(
38356
38965
  `Cannot synthesize a brief for "${recommendation.query}": its cited surface is ceded (dominated by aggregators/editorial). This is not a query first-party content can realistically win.`
38357
38966
  );
38358
38967
  }
38359
38968
  if (!body.forceRefresh) {
38360
38969
  const cached = lookupCachedBrief(app.db, project.id, targetRef, opts.briefPromptVersion);
38361
- if (cached) return reply.send(formatBriefRow(cached));
38970
+ if (cached) {
38971
+ settle({ status: OutcomeStatuses.succeeded, counts: { briefs: 1 } });
38972
+ return reply.send(formatBriefRow(cached));
38973
+ }
38362
38974
  }
38363
38975
  const result = await synthesizer({
38364
38976
  projectId: project.id,
@@ -38393,6 +39005,7 @@ async function contentRoutes(app, opts = {}) {
38393
39005
  generatedAt: now
38394
39006
  }
38395
39007
  }).run();
39008
+ settle({ status: OutcomeStatuses.succeeded, counts: generatedCounts("briefs", result.costMillicents) });
38396
39009
  const row = app.db.select().from(recommendationBriefs).where(and45(
38397
39010
  eq52(recommendationBriefs.projectId, project.id),
38398
39011
  eq52(recommendationBriefs.targetRef, targetRef),
@@ -38400,7 +39013,7 @@ async function contentRoutes(app, opts = {}) {
38400
39013
  )).get();
38401
39014
  if (!row) throw notFound("recommendationBrief", targetRef);
38402
39015
  return reply.send(formatBriefRow(row));
38403
- });
39016
+ }, contentFailureReason));
38404
39017
  app.get("/projects/:name/content/domain-classifications", async (request) => {
38405
39018
  const project = resolveProject(app.db, request.params.name);
38406
39019
  const rows = app.db.select().from(domainClassifications).where(eq52(domainClassifications.projectId, project.id)).orderBy(desc26(domainClassifications.hits)).all();
@@ -38449,7 +39062,7 @@ function winnabilityClassRank(winnabilityClass) {
38449
39062
  }
38450
39063
 
38451
39064
  // ../api-routes/src/openapi-schemas.ts
38452
- import { z } from "zod";
39065
+ import { z as z3 } from "zod";
38453
39066
  var SCHEMA_TABLE = {
38454
39067
  SentimentSummary: sentimentSummarySchema,
38455
39068
  SentimentSettings: sentimentSettingsSchema,
@@ -38725,7 +39338,7 @@ var SCHEMA_TABLE = {
38725
39338
  SnapshotListResponse: snapshotListResponseSchema,
38726
39339
  SnapshotReportDto: snapshotReportSchema,
38727
39340
  // The API accepts legacy phrases, but the public contract advertises queries.
38728
- SnapshotRequest: z.object(snapshotRequestInputSchema.shape).omit({ phrases: true }),
39341
+ SnapshotRequest: z3.object(snapshotRequestInputSchema.shape).omit({ phrases: true }),
38729
39342
  SourceBreakdownDto: sourceBreakdownDtoSchema,
38730
39343
  TelemetryEventAcceptedDto: telemetryEventAcceptedDtoSchema,
38731
39344
  FeedbackSubmission: feedbackSubmissionSchema,
@@ -38764,11 +39377,11 @@ var SCHEMA_TABLE = {
38764
39377
  // the API never returns this from a typed handler — it's emitted by the
38765
39378
  // global error handler. Codegen consumers reference it through the
38766
39379
  // ErrorEnvelope ref so error shape is part of the public contract.
38767
- ErrorEnvelope: z.object({
38768
- error: z.object({
38769
- code: z.string(),
38770
- message: z.string(),
38771
- details: z.unknown().optional()
39380
+ ErrorEnvelope: z3.object({
39381
+ error: z3.object({
39382
+ code: z3.string(),
39383
+ message: z3.string(),
39384
+ details: z3.unknown().optional()
38772
39385
  })
38773
39386
  })
38774
39387
  };
@@ -38793,7 +39406,7 @@ function buildComponentSchemas() {
38793
39406
  const out = {};
38794
39407
  for (const [name, schema] of Object.entries(SCHEMA_TABLE)) {
38795
39408
  const io = INPUT_FORM_SCHEMAS.has(name) ? "input" : "output";
38796
- const json = z.toJSONSchema(schema, { target: "openapi-3.0", io });
39409
+ const json = z3.toJSONSchema(schema, { target: "openapi-3.0", io });
38797
39410
  addNullToNullableEnums(json);
38798
39411
  out[name] = json;
38799
39412
  }
@@ -46812,7 +47425,7 @@ async function telemetryRoutes(app, opts) {
46812
47425
  if (typeof enabled !== "boolean") {
46813
47426
  throw validationError("enabled (boolean) is required");
46814
47427
  }
46815
- opts.setTelemetryEnabled(enabled);
47428
+ opts.setTelemetryEnabled(enabled, currentOutcomeAttribution());
46816
47429
  const status = opts.getTelemetryStatus?.();
46817
47430
  const effective = normalizeTelemetryStatus(status ?? { enabled });
46818
47431
  if (app.hasDecorator("db")) {
@@ -47132,311 +47745,32 @@ function formatSchedule(row) {
47132
47745
  // ../api-routes/src/notifications.ts
47133
47746
  import crypto30 from "crypto";
47134
47747
  import { eq as eq56 } from "drizzle-orm";
47135
-
47136
- // ../api-routes/src/notifications/alert.ts
47137
- import { z as z2 } from "zod";
47138
- var alertSeveritySchema = z2.enum(["critical", "warning", "success", "info"]);
47139
- var AlertSeverities = alertSeveritySchema.enum;
47140
- var AlertFieldLabels = {
47141
- status: "Status",
47142
- check: "Check",
47143
- previously: "Was",
47144
- remediation: "What to do",
47145
- alsoFailing: "Also failing",
47146
- event: "Event",
47147
- run: "Run",
47148
- changes: "Changes",
47149
- insights: "Insights",
47150
- location: "Location",
47151
- rating: "Rating",
47152
- reviewer: "Reviewer",
47153
- replied: "Replied",
47154
- source: "Source"
47155
- };
47156
- var HEALTH_EVENTS = /* @__PURE__ */ new Set(["health.degraded", "health.recovered"]);
47157
- function isHealth(payload) {
47158
- return HEALTH_EVENTS.has(payload.event);
47159
- }
47160
- var MAX_LIST_ITEMS = 10;
47161
- function bulletList(lines) {
47162
- const shown = lines.slice(0, MAX_LIST_ITEMS);
47163
- const omitted = lines.length - shown.length;
47164
- const body = shown.map((line) => `\u2022 ${line}`).join("\n");
47165
- return omitted > 0 ? `${body}
47166
- \u2022 \u2026and ${omitted} more` : body;
47167
- }
47168
- function healthView(payload) {
47169
- const { health, project } = payload;
47170
- const recovered = payload.event === "health.recovered";
47171
- const severity = recovered ? AlertSeverities.success : health.status === "fail" ? AlertSeverities.critical : AlertSeverities.warning;
47172
- const fields = [
47173
- { label: AlertFieldLabels.status, value: recovered ? "recovered" : health.status, compact: true },
47174
- { label: AlertFieldLabels.check, value: health.code, compact: true }
47175
- ];
47176
- if (health.previousStatus) {
47177
- fields.push({ label: AlertFieldLabels.previously, value: health.previousStatus, compact: true });
47178
- }
47179
- if (health.remediation) {
47180
- fields.push({ label: AlertFieldLabels.remediation, value: health.remediation });
47181
- }
47182
- const others = health.failing.filter((check2) => check2.code !== health.code);
47183
- if (others.length > 0) {
47184
- fields.push({
47185
- label: `${AlertFieldLabels.alsoFailing} (${others.length})`,
47186
- value: bulletList(others.map((check2) => `${check2.status} \u2014 ${check2.code}`))
47187
- });
47188
- }
47189
- const website = health.code.startsWith("site.reachability.");
47190
- return {
47191
- severity,
47192
- title: website ? recovered ? `${project.name} website is back up` : `${project.name} website is down` : recovered ? `${project.name} recovered` : `${project.name} \u2014 measurement degraded`,
47193
- body: health.summary,
47194
- fields,
47195
- url: payload.dashboardUrl,
47196
- footer: project.canonicalDomain,
47197
- timestamp: health.checkedAt
47198
- };
47199
- }
47200
- function runView(payload) {
47201
- const failed = payload.event === "run.failed";
47202
- const transitions = "transitions" in payload ? payload.transitions : [];
47203
- const insights2 = "insights" in payload ? payload.insights : [];
47204
- const fields = [
47205
- { label: AlertFieldLabels.event, value: payload.event, compact: true },
47206
- { label: AlertFieldLabels.run, value: payload.run.status, compact: true }
47207
- ];
47208
- if (transitions.length > 0) {
47209
- fields.push({
47210
- label: `${AlertFieldLabels.changes} (${transitions.length})`,
47211
- value: bulletList(transitions.map((t) => `${t.provider}: ${t.from} \u2192 ${t.to} \u2014 ${t.query}`))
47212
- });
47213
- }
47214
- if (insights2.length > 0) {
47215
- fields.push({
47216
- label: `${AlertFieldLabels.insights} (${insights2.length})`,
47217
- value: bulletList(insights2.map((i) => `[${i.severity}] ${i.title}`))
47218
- });
47219
- }
47220
- return {
47221
- severity: failed ? AlertSeverities.critical : AlertSeverities.info,
47222
- title: payload.project.name,
47223
- fields,
47224
- url: payload.dashboardUrl,
47225
- footer: payload.project.canonicalDomain,
47226
- ...payload.run.finishedAt ? { timestamp: payload.run.finishedAt } : {}
47227
- };
47228
- }
47229
- function stars(rating) {
47230
- const filled = Math.max(0, Math.min(5, Math.round(rating)));
47231
- return `${"\u2605".repeat(filled)}${"\u2606".repeat(5 - filled)}`;
47232
- }
47233
- function snippet(text2, max2 = 160) {
47234
- const flat = text2.replace(/\s+/g, " ").trim();
47235
- return flat.length > max2 ? `${flat.slice(0, max2 - 1)}\u2026` : flat;
47236
- }
47237
- var SOURCE_LABELS = {
47238
- gbp: "Business Profile",
47239
- places: "Public listing (Places, partial)"
47240
- };
47241
- function reviewView(payload) {
47242
- const { project, reviews } = payload;
47243
- const newest = reviews.reduce((latest, r) => !latest || r.updateTime > latest ? r.updateTime : latest, void 0);
47244
- const origins = [...new Set(reviews.map((r) => r.origin))];
47245
- const sourceField = {
47246
- label: AlertFieldLabels.source,
47247
- value: origins.map((origin) => SOURCE_LABELS[origin]).join(", "),
47248
- compact: true
47249
- };
47250
- if (reviews.length === 1) {
47251
- const review = reviews[0];
47252
- const fields = [
47253
- { label: AlertFieldLabels.location, value: review.location.displayName, compact: true },
47254
- { label: AlertFieldLabels.rating, value: `${stars(review.starRating)} (${review.starRating}/5)`, compact: true }
47255
- ];
47256
- if (review.reviewerName) fields.push({ label: AlertFieldLabels.reviewer, value: review.reviewerName, compact: true });
47257
- if (review.replied !== null) fields.push({ label: AlertFieldLabels.replied, value: review.replied ? "yes" : "no", compact: true });
47258
- fields.push(sourceField);
47259
- return {
47260
- severity: AlertSeverities.warning,
47261
- title: `${project.name}: new ${review.starRating}-star review`,
47262
- body: review.comment ?? "Rating only, no text.",
47263
- fields,
47264
- url: review.reviewUri ?? review.location.mapsUri ?? payload.dashboardUrl,
47265
- footer: project.canonicalDomain,
47266
- timestamp: review.updateTime
47267
- };
47268
- }
47269
- return {
47270
- severity: AlertSeverities.warning,
47271
- title: `${project.name}: ${reviews.length} new negative reviews`,
47272
- body: bulletList(reviews.map((r) => {
47273
- const text2 = r.comment ? ` "${snippet(r.comment)}"` : "";
47274
- const by = r.reviewerName ? ` (${r.reviewerName})` : "";
47275
- return `${stars(r.starRating)} ${r.location.displayName}:${text2}${by}`;
47276
- })),
47277
- fields: [sourceField],
47278
- url: payload.dashboardUrl,
47279
- footer: project.canonicalDomain,
47280
- ...newest ? { timestamp: newest } : {}
47281
- };
47282
- }
47283
- function ratingView(payload) {
47284
- const { project, ratings } = payload;
47285
- const line = (r) => {
47286
- const counts2 = r.previousReviewCount !== null && r.reviewCount !== null ? ` (${r.previousReviewCount} \u2192 ${r.reviewCount} reviews)` : "";
47287
- return `${r.location.displayName}: ${r.previousRating} \u2192 ${r.rating}${counts2}`;
47288
- };
47289
- const single = ratings.length === 1 ? ratings[0] : null;
47290
- return {
47291
- severity: AlertSeverities.warning,
47292
- title: single ? `${project.name}: Google rating fell to ${single.rating}` : `${project.name}: Google rating fell at ${ratings.length} locations`,
47293
- body: single ? line(single) : bulletList(ratings.map(line)),
47294
- fields: [
47295
- { label: AlertFieldLabels.source, value: SOURCE_LABELS.places, compact: true },
47296
- {
47297
- label: AlertFieldLabels.remediation,
47298
- value: "Read the newest reviews on Google Maps. Without Business Profile review access, Canonry sees at most five of them."
47299
- }
47300
- ],
47301
- url: single?.location.mapsUri ?? payload.dashboardUrl,
47302
- footer: project.canonicalDomain,
47303
- timestamp: ratings.reduce((latest, r) => r.observedAt > latest ? r.observedAt : latest, ratings[0]?.observedAt ?? "")
47304
- };
47305
- }
47306
- function toAlertView(payload) {
47307
- if (isHealth(payload)) return healthView(payload);
47308
- if (payload.event === "review.negative") return reviewView(payload);
47309
- if (payload.event === "review.rating-dropped") return ratingView(payload);
47310
- return runView(payload);
47311
- }
47312
-
47313
- // ../api-routes/src/notifications/destinations.ts
47314
- import { z as z3 } from "zod";
47315
- var webhookDestinationSchema = z3.enum(["first-party", "discord", "slack"]);
47316
- var WebhookDestinations = webhookDestinationSchema.enum;
47317
- function hostIs(url, ...domains) {
47318
- const host = url.hostname.toLowerCase();
47319
- return domains.some((domain) => host === domain || host.endsWith(`.${domain}`));
47320
- }
47321
- var DISCORD_MAX_DESCRIPTION = 3800;
47322
- var DISCORD_MAX_FIELD = 1e3;
47323
- var DISCORD_COLORS = {
47324
- [AlertSeverities.critical]: 14235195,
47325
- [AlertSeverities.warning]: 14256926,
47326
- [AlertSeverities.success]: 3055183,
47327
- [AlertSeverities.info]: 4873068
47328
- };
47329
- var SEVERITY_ICONS = {
47330
- [AlertSeverities.critical]: "\u{1F534}",
47331
- [AlertSeverities.warning]: "\u{1F7E0}",
47332
- [AlertSeverities.success]: "\u2705",
47333
- [AlertSeverities.info]: "\u{1F4CA}"
47334
- };
47335
- function clamp(value, max2) {
47336
- return value.length <= max2 ? value : `${value.slice(0, max2 - 1)}\u2026`;
47337
- }
47338
- function absoluteLink(url) {
47339
- if (!url) return void 0;
47340
- try {
47341
- const parsed = new URL(url);
47342
- return parsed.protocol === "https:" || parsed.protocol === "http:" ? url : void 0;
47343
- } catch {
47344
- return void 0;
47345
- }
47346
- }
47347
- function renderDiscord(view) {
47748
+ var AGENT_WEBHOOK_SOURCE = "agent";
47749
+ function webhookConnection(action, url, source) {
47348
47750
  return {
47349
- username: "Canonry",
47350
- embeds: [{
47351
- title: `${SEVERITY_ICONS[view.severity]} ${view.title}`,
47352
- ...view.body ? { description: clamp(view.body, DISCORD_MAX_DESCRIPTION) } : {},
47353
- color: DISCORD_COLORS[view.severity],
47354
- fields: view.fields.map((field) => ({
47355
- name: field.label,
47356
- value: clamp(field.value, DISCORD_MAX_FIELD),
47357
- ...field.compact ? { inline: true } : {}
47358
- })),
47359
- ...absoluteLink(view.url) ? { url: absoluteLink(view.url) } : {},
47360
- ...view.timestamp ? { timestamp: view.timestamp } : {},
47361
- ...view.footer ? { footer: { text: view.footer } } : {}
47362
- }]
47363
- };
47364
- }
47365
- var SLACK_MAX_TEXT = 2900;
47366
- var SLACK_MAX_FIELD = 1e3;
47367
- var SLACK_COLORS = {
47368
- [AlertSeverities.critical]: "#d9363b",
47369
- [AlertSeverities.warning]: "#d98b1e",
47370
- [AlertSeverities.success]: "#2e9e4f",
47371
- [AlertSeverities.info]: "#4a5b6c"
47372
- };
47373
- function renderSlack(view) {
47374
- const headline = `${SEVERITY_ICONS[view.severity]} ${view.title}`;
47375
- return {
47376
- // `text` is the notification preview and the accessible fallback, so it
47377
- // must stand alone rather than repeat an empty string.
47378
- text: headline,
47379
- attachments: [{
47380
- color: SLACK_COLORS[view.severity],
47381
- title: headline,
47382
- ...absoluteLink(view.url) ? { title_link: absoluteLink(view.url) } : {},
47383
- ...view.body ? { text: clamp(view.body, SLACK_MAX_TEXT) } : {},
47384
- fields: view.fields.map((field) => ({
47385
- title: field.label,
47386
- value: clamp(field.value, SLACK_MAX_FIELD),
47387
- ...field.compact ? { short: true } : {}
47388
- })),
47389
- ...view.footer ? { footer: view.footer } : {},
47390
- ...view.timestamp ? { ts: Math.floor(new Date(view.timestamp).getTime() / 1e3) } : {}
47391
- }]
47751
+ integration: source === AGENT_WEBHOOK_SOURCE ? "agent_webhook" : "webhook",
47752
+ action,
47753
+ ...typeof url === "string" && url ? { target: webhookOutcomeTarget(url) } : {}
47392
47754
  };
47393
47755
  }
47394
- var DISCORD_ADAPTER = {
47395
- destination: WebhookDestinations.discord,
47396
- matches: (url) => hostIs(url, "discord.com", "discordapp.com") && url.pathname.startsWith("/api/webhooks/"),
47397
- render: renderDiscord,
47398
- signed: false
47399
- };
47400
- var SLACK_ADAPTER = {
47401
- destination: WebhookDestinations.slack,
47402
- matches: (url) => hostIs(url, "slack.com") && url.pathname.startsWith("/services/"),
47403
- render: renderSlack,
47404
- signed: false
47405
- };
47406
- var FIRST_PARTY_ADAPTER = {
47407
- destination: WebhookDestinations["first-party"],
47408
- matches: () => true,
47409
- signed: true
47410
- };
47411
- var DESTINATION_ADAPTERS = [
47412
- DISCORD_ADAPTER,
47413
- SLACK_ADAPTER,
47414
- FIRST_PARTY_ADAPTER
47415
- ];
47416
- function resolveDestination(rawUrl) {
47417
- let url;
47418
- try {
47419
- url = new URL(rawUrl);
47420
- } catch {
47421
- return FIRST_PARTY_ADAPTER;
47422
- }
47423
- if (url.protocol !== "https:") return FIRST_PARTY_ADAPTER;
47424
- return DESTINATION_ADAPTERS.find((adapter) => adapter.matches(url)) ?? FIRST_PARTY_ADAPTER;
47425
- }
47426
-
47427
- // ../api-routes/src/notifications.ts
47428
47756
  var VALID_EVENTS = notificationEventSchema.options;
47429
47757
  async function notificationRoutes(app, opts = {}) {
47430
47758
  const allowLoopback = opts.allowLoopbackWebhooks === true;
47431
47759
  app.get("/notifications/events", async (_request, reply) => {
47432
47760
  return reply.send(VALID_EVENTS);
47433
47761
  });
47434
- app.post("/projects/:name/notifications", async (request, reply) => {
47762
+ app.post("/projects/:name/notifications", connectionRoute(app, (request) => {
47763
+ const body = request.body;
47764
+ return webhookConnection("connect", body?.url, body?.source);
47765
+ }, async (request, reply, attempt) => {
47435
47766
  const project = resolveProject(app.db, request.params.name);
47436
47767
  const { channel, url, events, source } = request.body ?? {};
47437
47768
  if (channel !== "webhook") throw validationError('Only "webhook" channel is supported');
47438
47769
  const urlCheck = await resolveWebhookTarget(url ?? "", { allowLoopback });
47439
- if (!urlCheck.ok) throw validationError(urlCheck.message);
47770
+ if (!urlCheck.ok) {
47771
+ attempt.failed(void 0, webhookTargetRefusalReason(urlCheck));
47772
+ throw validationError(urlCheck.message);
47773
+ }
47440
47774
  if (!events?.length) throw validationError('"events" must be a non-empty array');
47441
47775
  const invalid = events.filter((e) => !VALID_EVENTS.includes(e));
47442
47776
  if (invalid.length) {
@@ -47467,13 +47801,17 @@ async function notificationRoutes(app, opts = {}) {
47467
47801
  ...formatNotification(app.db.select().from(notifications).where(eq56(notifications.id, id)).get()),
47468
47802
  webhookSecret
47469
47803
  });
47470
- });
47804
+ }));
47471
47805
  app.get("/projects/:name/notifications", async (request, reply) => {
47472
47806
  const project = resolveProject(app.db, request.params.name);
47473
47807
  const rows = app.db.select().from(notifications).where(eq56(notifications.projectId, project.id)).all();
47474
47808
  return reply.send(rows.map(formatNotification));
47475
47809
  });
47476
- app.delete("/projects/:name/notifications/:id", async (request, reply) => {
47810
+ app.delete("/projects/:name/notifications/:id", connectionRoute(app, (request) => {
47811
+ const stored = app.db.select({ config: notifications.config }).from(notifications).where(eq56(notifications.id, request.params.id)).get();
47812
+ const config = stored?.config;
47813
+ return config ? webhookConnection("disconnect", config.url, config.source) : void 0;
47814
+ }, async (request, reply) => {
47477
47815
  const project = resolveProject(app.db, request.params.name);
47478
47816
  const notification = app.db.select().from(notifications).where(eq56(notifications.id, request.params.id)).get();
47479
47817
  if (!notification || notification.projectId !== project.id) {
@@ -47488,8 +47826,12 @@ async function notificationRoutes(app, opts = {}) {
47488
47826
  entityId: notification.id
47489
47827
  });
47490
47828
  return reply.status(204).send();
47491
- });
47492
- app.post("/projects/:name/notifications/:id/test", async (request, reply) => {
47829
+ }));
47830
+ app.post("/projects/:name/notifications/:id/test", connectionRoute(app, (request) => {
47831
+ const stored = app.db.select({ config: notifications.config }).from(notifications).where(eq56(notifications.id, request.params.id)).get();
47832
+ const config = stored?.config;
47833
+ return config ? webhookConnection("test", config.url, config.source) : void 0;
47834
+ }, async (request, reply, attempt) => {
47493
47835
  const project = resolveProject(app.db, request.params.name);
47494
47836
  const notification = app.db.select().from(notifications).where(eq56(notifications.id, request.params.id)).get();
47495
47837
  if (!notification || notification.projectId !== project.id) {
@@ -47497,7 +47839,10 @@ async function notificationRoutes(app, opts = {}) {
47497
47839
  }
47498
47840
  const config = notification.config;
47499
47841
  const urlCheck = await resolveWebhookTarget(config.url, { allowLoopback });
47500
- if (!urlCheck.ok) throw validationError(`Stored webhook URL is invalid: ${urlCheck.message}`);
47842
+ if (!urlCheck.ok) {
47843
+ attempt.failed(void 0, webhookTargetRefusalReason(urlCheck));
47844
+ throw validationError(`Stored webhook URL is invalid: ${urlCheck.message}`);
47845
+ }
47501
47846
  const payload = {
47502
47847
  source: "canonry",
47503
47848
  event: "run.completed",
@@ -47516,8 +47861,11 @@ async function notificationRoutes(app, opts = {}) {
47516
47861
  const signingSecret = destination.signed ? notification.webhookSecret ?? null : null;
47517
47862
  const targetLabel = redactNotificationUrl(config.url).urlDisplay;
47518
47863
  request.log.info(`[Notification test] POST ${targetLabel} (${destination.destination})`);
47519
- const { status, error } = await deliverWebhook(urlCheck.target, body, signingSecret);
47864
+ const delivery = await deliverWebhook(urlCheck.target, body, signingSecret);
47865
+ const { status, error } = delivery;
47520
47866
  request.log.info(`[Notification test] Response: HTTP ${status} from ${targetLabel}`);
47867
+ const failure = webhookResponseReason(delivery);
47868
+ if (failure) attempt.failed(void 0, failure);
47521
47869
  writeAuditLog(app.db, {
47522
47870
  projectId: project.id,
47523
47871
  actor: "api",
@@ -47528,7 +47876,7 @@ async function notificationRoutes(app, opts = {}) {
47528
47876
  });
47529
47877
  if (error) throw deliveryFailed(error);
47530
47878
  return reply.send({ status, ok: status >= 200 && status < 300 });
47531
- });
47879
+ }));
47532
47880
  }
47533
47881
  function formatNotification(row) {
47534
47882
  const config = row.config;
@@ -49769,6 +50117,10 @@ function classifyReviewsError(err) {
49769
50117
  }
49770
50118
 
49771
50119
  // ../api-routes/src/google.ts
50120
+ var GOOGLE_INTEGRATIONS = { gsc: "gsc", ga4: "ga4", gbp: "gbp" };
50121
+ function googleIntegration(type) {
50122
+ return typeof type === "string" && Object.hasOwn(GOOGLE_INTEGRATIONS, type) ? GOOGLE_INTEGRATIONS[type] : void 0;
50123
+ }
49772
50124
  function resolveReportedWindow(resolved, startDate, endDate) {
49773
50125
  const start = startDate ?? resolved.startDate;
49774
50126
  const end = endDate ?? resolved.endDate;
@@ -49902,6 +50254,19 @@ function googleAuthErrorStatus(err) {
49902
50254
  const match = err.message.match(/failed \((\d{3})\)/);
49903
50255
  return match ? Number(match[1]) : null;
49904
50256
  }
50257
+ function gscOutcomeFailure(err) {
50258
+ const classified = classifyOutcomeError(err);
50259
+ if (err instanceof AppError && (err.code === "NOT_FOUND" || err.code === "VALIDATION_ERROR")) {
50260
+ return { ...classified, reasonCode: OutcomeReasonCodes.NOT_CONNECTED };
50261
+ }
50262
+ if (err instanceof GoogleAuthError) {
50263
+ const status = googleAuthErrorStatus(err);
50264
+ const reasonCode = status === 429 ? OutcomeReasonCodes.RATE_LIMITED : status !== null && status >= 500 ? OutcomeReasonCodes.HTTP_5XX : OutcomeReasonCodes.INVALID_CREDENTIALS;
50265
+ return { ...classified, reasonCode };
50266
+ }
50267
+ if (err instanceof GoogleApiError && err.status === 404) return { ...classified, reasonCode: OutcomeReasonCodes.PROPERTY_NOT_FOUND };
50268
+ return classified;
50269
+ }
49905
50270
  function gscErrorToAppError(err, context) {
49906
50271
  if (err instanceof AppError) return err;
49907
50272
  if (err instanceof GoogleApiError) {
@@ -49985,7 +50350,10 @@ async function googleRoutes(app, opts) {
49985
50350
  updatedAt: connection.updatedAt
49986
50351
  }));
49987
50352
  });
49988
- app.post("/projects/:name/google/connect", async (request) => {
50353
+ app.post("/projects/:name/google/connect", connectionRoute(app, (request) => {
50354
+ const integration = googleIntegration(request.body?.type);
50355
+ return integration ? { integration, action: "connect" } : void 0;
50356
+ }, async (request, _reply, attempt) => {
49989
50357
  const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
49990
50358
  if (!googleClientId || !googleClientSecret) {
49991
50359
  throw validationError("Google OAuth is not configured. Set Google OAuth credentials in the local Canonry config.");
@@ -49995,6 +50363,7 @@ async function googleRoutes(app, opts) {
49995
50363
  throw validationError('type must be "gsc", "ga4", or "gbp"');
49996
50364
  }
49997
50365
  const project = resolveProject(app.db, request.params.name);
50366
+ if (telemetryRead(() => opts.googleConnectionStore?.getConnection(project.canonicalDomain, type), void 0)) attempt.update({ action: "reauth" });
49998
50367
  let redirectUri;
49999
50368
  if (publicUrl) {
50000
50369
  redirectUri = publicUrl.replace(/\/$/, "") + "/api/v1/google/callback";
@@ -50013,22 +50382,40 @@ async function googleRoutes(app, opts) {
50013
50382
  domain: project.canonicalDomain,
50014
50383
  type,
50015
50384
  propertyId,
50016
- redirectUri
50385
+ redirectUri,
50386
+ // The browser finishes the flow, so the callback reports the surface that started it.
50387
+ ...oauthStartAttribution()
50017
50388
  },
50018
50389
  stateSecret
50019
50390
  );
50020
50391
  const authUrl = getAuthUrl(googleClientId, redirectUri, scopes, stateEncoded);
50392
+ attempt.started();
50021
50393
  return { authUrl, redirectUri };
50022
- });
50394
+ }));
50023
50395
  async function handleOAuthCallback(request, reply) {
50396
+ const verifiedState = request.query.state ? verifySignedGoogleOAuthState(request.query.state, stateSecret) : null;
50397
+ const integration = googleIntegration(verifiedState?.type ?? unverifiedOAuthStateField(request.query.state, "type"));
50398
+ const replacesConnection = integration && typeof verifiedState?.domain === "string" && telemetryRead(() => opts.googleConnectionStore?.getConnection(verifiedState.domain, integration), void 0);
50399
+ const attempt = startConnectionAttempt(app, integration ? { integration, action: replacesConnection ? "reauth" : "connect", ...oauthStateAttribution(verifiedState) } : void 0);
50400
+ try {
50401
+ return await completeOAuthCallback(request, reply, attempt);
50402
+ } catch (err) {
50403
+ attempt.failed(err);
50404
+ throw err;
50405
+ }
50406
+ }
50407
+ async function completeOAuthCallback(request, reply, attempt) {
50024
50408
  const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
50025
50409
  if (!googleClientId || !googleClientSecret) {
50410
+ attempt.failed(void 0, "UNSUPPORTED");
50026
50411
  return reply.status(500).send("Google OAuth not configured");
50027
50412
  }
50028
50413
  const store = requireConnectionStore();
50029
50414
  const escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
50030
50415
  const { code, state, error } = request.query;
50031
50416
  if (error) {
50417
+ if (error === "access_denied") attempt.cancelled("OAUTH_CANCELLED");
50418
+ else attempt.failed(void 0, "AUTH_DENIED");
50032
50419
  const safeError = escapeHtml3(String(error));
50033
50420
  const errorHtml = error === "redirect_uri_mismatch" ? `<html><body style="font-family:system-ui;padding:40px;max-width:600px;margin:0 auto">
50034
50421
  <h2 style="color:#ef4444">Redirect URI mismatch</h2>
@@ -50055,21 +50442,26 @@ async function googleRoutes(app, opts) {
50055
50442
  return reply.type("text/html").send(errorHtml);
50056
50443
  }
50057
50444
  if (!code || !state) {
50445
+ attempt.failed(void 0, state ? "VALIDATION" : "OAUTH_STATE_INVALID");
50058
50446
  return reply.status(400).send("Missing code or state parameter");
50059
50447
  }
50060
50448
  const stateData = verifySignedGoogleOAuthState(state, stateSecret);
50061
50449
  if (!stateData) {
50450
+ attempt.failed(void 0, "OAUTH_STATE_INVALID");
50062
50451
  return reply.status(400).send("Invalid or tampered state parameter");
50063
50452
  }
50064
50453
  const { domain, type, propertyId, redirectUri, projectId, projectName } = stateData;
50065
50454
  if (!projectId) {
50455
+ attempt.failed(void 0, "OAUTH_STATE_INVALID");
50066
50456
  return reply.status(400).send("Stale OAuth state \u2014 restart the connect flow.");
50067
50457
  }
50068
50458
  const project = app.db.select().from(projects).where(eq57(projects.id, projectId)).get();
50069
50459
  if (!project) {
50460
+ attempt.failed(void 0, "NOT_FOUND");
50070
50461
  return reply.status(400).send("Project no longer exists. Restart the connect flow.");
50071
50462
  }
50072
50463
  if (project.canonicalDomain.toLowerCase() !== domain.toLowerCase()) {
50464
+ attempt.failed(void 0, "OAUTH_STATE_INVALID");
50073
50465
  return reply.status(400).send(
50074
50466
  `Project "${projectName ?? project.name}" canonical domain changed since this OAuth flow started. Expected "${domain}", got "${project.canonicalDomain}". Restart the connect flow.`
50075
50467
  );
@@ -50078,6 +50470,7 @@ async function googleRoutes(app, opts) {
50078
50470
  try {
50079
50471
  tokens = await exchangeCode(googleClientId, googleClientSecret, code, redirectUri);
50080
50472
  } catch (err) {
50473
+ attempt.failed(err);
50081
50474
  const msg = describeError(err);
50082
50475
  return reply.type("text/html").send(
50083
50476
  `<html><body style="font-family:system-ui;padding:40px;max-width:600px;margin:0 auto">
@@ -50095,6 +50488,7 @@ async function googleRoutes(app, opts) {
50095
50488
  const expiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
50096
50489
  const existing = store.getConnection(domain, type);
50097
50490
  if (existing && existing.createdByProjectId && existing.createdByProjectId !== projectId) {
50491
+ attempt.failed(void 0, "ALREADY_CONNECTED");
50098
50492
  return reply.status(403).send(
50099
50493
  `This domain already has a Google ${String(type).toUpperCase()} connection owned by another project. Disconnect it from that project first (DELETE /api/v1/projects/<owner>/google/connections/${escapeHtml3(String(type))}) before re-connecting from "${escapeHtml3(projectName ?? "")}".`
50100
50494
  );
@@ -50121,6 +50515,7 @@ async function googleRoutes(app, opts) {
50121
50515
  entityId: type,
50122
50516
  diff: { domain, type, propertyId }
50123
50517
  });
50518
+ attempt.succeeded();
50124
50519
  return reply.type("text/html").send(googleOAuthSuccessHtml(type));
50125
50520
  }
50126
50521
  app.get("/google/callback", async (request, reply) => {
@@ -50129,12 +50524,16 @@ async function googleRoutes(app, opts) {
50129
50524
  app.get("/projects/:name/google/callback", async (request, reply) => {
50130
50525
  return handleOAuthCallback(request, reply);
50131
50526
  });
50132
- app.delete("/projects/:name/google/connections/:type", async (request, reply) => {
50527
+ app.delete("/projects/:name/google/connections/:type", connectionRoute(app, (request) => {
50528
+ const integration = googleIntegration(request.params.type);
50529
+ return integration ? { integration, action: "disconnect" } : void 0;
50530
+ }, async (request, reply, attempt) => {
50133
50531
  const store = requireConnectionStore();
50134
50532
  const project = resolveProject(app.db, request.params.name);
50135
50533
  const type = request.params.type;
50136
50534
  const existing = store.getConnection(project.canonicalDomain, type);
50137
50535
  if (!existing) {
50536
+ attempt.failed(void 0, "NOT_CONNECTED");
50138
50537
  throw notFound("Google connection", type);
50139
50538
  }
50140
50539
  if (existing.createdByProjectId && existing.createdByProjectId !== project.id) {
@@ -50144,6 +50543,7 @@ async function googleRoutes(app, opts) {
50144
50543
  }
50145
50544
  const deleted = store.deleteConnection(project.canonicalDomain, type);
50146
50545
  if (!deleted) {
50546
+ attempt.failed(void 0, "NOT_CONNECTED");
50147
50547
  throw notFound("Google connection", type);
50148
50548
  }
50149
50549
  writeAuditLog(app.db, {
@@ -50154,7 +50554,7 @@ async function googleRoutes(app, opts) {
50154
50554
  entityId: type
50155
50555
  });
50156
50556
  return reply.status(204).send();
50157
- });
50557
+ }));
50158
50558
  app.get("/projects/:name/google/properties", async (request) => {
50159
50559
  assertNotProjectScoped(request, "listing Search Console properties");
50160
50560
  const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
@@ -50472,14 +50872,16 @@ async function googleRoutes(app, opts) {
50472
50872
  if (!url) {
50473
50873
  throw validationError("url is required");
50474
50874
  }
50875
+ const outcome2 = startRouteOutcome(app, FeatureNames.search_console, "inspect");
50475
50876
  let result;
50476
50877
  try {
50477
50878
  const { accessToken, propertyId } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
50478
50879
  if (!propertyId) {
50479
- throw validationError("No GSC property configured for this connection");
50880
+ throw outcome2.refuse(OutcomeReasonCodes.PROPERTY_NOT_FOUND, validationError("No GSC property configured for this connection"));
50480
50881
  }
50481
50882
  result = await inspectUrl(accessToken, url, propertyId);
50482
50883
  } catch (err) {
50884
+ outcome2.report({ status: OutcomeStatuses.failed, ...gscOutcomeFailure(err) });
50483
50885
  throw gscErrorToAppError(err, "Failed to inspect URL in Search Console");
50484
50886
  }
50485
50887
  const ir = result.inspectionResult;
@@ -50506,6 +50908,7 @@ async function googleRoutes(app, opts) {
50506
50908
  inspectedAt: now,
50507
50909
  createdAt: now
50508
50910
  }).run();
50911
+ outcome2.report({ status: OutcomeStatuses.succeeded, counts: { urls: 1 } });
50509
50912
  return {
50510
50913
  id,
50511
50914
  url,
@@ -50730,31 +51133,43 @@ async function googleRoutes(app, opts) {
50730
51133
  const sitemapUrls = [...new Set(parsed.data.sitemapUrls)];
50731
51134
  const project = resolveProject(app.db, request.params.name);
50732
51135
  const store = requireConnectionStore();
51136
+ const outcome2 = startRouteOutcome(app, FeatureNames.search_console, "sitemap_submit");
50733
51137
  const conn = store.getConnection(project.canonicalDomain, "gsc");
50734
51138
  if (!conn) {
50735
- throw validationError('No GSC connection found for this domain. Run "canonry google connect" first.');
51139
+ throw outcome2.refuse(OutcomeReasonCodes.NOT_CONNECTED, validationError('No GSC connection found for this domain. Run "canonry google connect" first.'));
50736
51140
  }
50737
51141
  if (!conn.propertyId) {
50738
- throw validationError('No GSC property configured for this connection. Set one with "canonry google set-property".');
51142
+ throw outcome2.refuse(
51143
+ OutcomeReasonCodes.PROPERTY_NOT_FOUND,
51144
+ validationError('No GSC property configured for this connection. Set one with "canonry google set-property".')
51145
+ );
50739
51146
  }
50740
51147
  const invalidUrls = sitemapUrls.filter((url) => !isSitemapOwnedByProperty(url, conn.propertyId, project.canonicalDomain));
50741
51148
  if (invalidUrls.length > 0) {
50742
- throw validationError(`Sitemap URLs must belong to the configured GSC property "${conn.propertyId}". Invalid: ${invalidUrls.slice(0, 5).join(", ")}`);
51149
+ throw outcome2.refuse(
51150
+ OutcomeReasonCodes.VALIDATION,
51151
+ validationError(`Sitemap URLs must belong to the configured GSC property "${conn.propertyId}". Invalid: ${invalidUrls.slice(0, 5).join(", ")}`)
51152
+ );
50743
51153
  }
50744
51154
  if (!(conn.scopes ?? []).includes(GSC_SCOPE)) {
50745
- throw validationError('This GSC connection has the read-only webmasters scope and cannot submit sitemaps. Reconnect with "canonry google connect" to grant the full webmasters scope.');
51155
+ throw outcome2.refuse(
51156
+ OutcomeReasonCodes.PERMISSION_MISSING,
51157
+ validationError('This GSC connection has the read-only webmasters scope and cannot submit sitemaps. Reconnect with "canonry google connect" to grant the full webmasters scope.')
51158
+ );
50746
51159
  }
50747
51160
  const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
50748
51161
  if (!googleClientId || !googleClientSecret) {
50749
- throw validationError("Google OAuth is not configured");
51162
+ throw outcome2.refuse(OutcomeReasonCodes.NOT_CONNECTED, validationError("Google OAuth is not configured"));
50750
51163
  }
50751
51164
  let accessToken;
50752
51165
  try {
50753
51166
  ({ accessToken } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret));
50754
51167
  } catch (err) {
51168
+ outcome2.report({ status: OutcomeStatuses.failed, ...gscOutcomeFailure(err) });
50755
51169
  throw gscErrorToAppError(err, "Failed to authorize Search Console sitemap submission");
50756
51170
  }
50757
51171
  const results = [];
51172
+ let lastSubmitError;
50758
51173
  for (const sitemapUrl of sitemapUrls) {
50759
51174
  try {
50760
51175
  await submitSitemap(accessToken, conn.propertyId, sitemapUrl);
@@ -50768,11 +51183,15 @@ async function googleRoutes(app, opts) {
50768
51183
  });
50769
51184
  results.push({ sitemapUrl, status: "accepted", submittedAt });
50770
51185
  } catch (err) {
51186
+ lastSubmitError = err;
50771
51187
  results.push({ sitemapUrl, status: "error", error: describeError(err) });
50772
51188
  }
50773
51189
  }
50774
51190
  const accepted = results.filter((result) => result.status === "accepted").length;
50775
- return { summary: { total: results.length, accepted, failed: results.length - accepted }, results };
51191
+ const failed = results.length - accepted;
51192
+ const counts2 = { urls: accepted, failures: failed };
51193
+ outcome2.report(failed === 0 ? { status: OutcomeStatuses.succeeded, counts: counts2 } : { status: accepted > 0 ? OutcomeStatuses.partial : OutcomeStatuses.failed, ...gscOutcomeFailure(lastSubmitError), counts: counts2 });
51194
+ return { summary: { total: results.length, accepted, failed }, results };
50776
51195
  });
50777
51196
  app.post("/projects/:name/google/gsc/discover-sitemaps", async (request) => {
50778
51197
  const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
@@ -50866,7 +51285,10 @@ async function googleRoutes(app, opts) {
50866
51285
  }
50867
51286
  return { sitemapUrl: sitemapUrl.trim() };
50868
51287
  });
50869
- app.put("/projects/:name/google/connections/:type/property", async (request) => {
51288
+ app.put("/projects/:name/google/connections/:type/property", connectionRoute(app, (request) => {
51289
+ const integration = googleIntegration(request.params.type);
51290
+ return integration ? { integration, action: "select" } : void 0;
51291
+ }, async (request, _reply, attempt) => {
50870
51292
  const store = requireConnectionStore();
50871
51293
  const project = resolveProject(app.db, request.params.name);
50872
51294
  const { propertyId } = request.body ?? {};
@@ -50879,10 +51301,11 @@ async function googleRoutes(app, opts) {
50879
51301
  { propertyId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
50880
51302
  );
50881
51303
  if (!conn) {
51304
+ attempt.failed(void 0, "NOT_CONNECTED");
50882
51305
  throw notFound("Google connection", request.params.type);
50883
51306
  }
50884
51307
  return { propertyId };
50885
- });
51308
+ }));
50886
51309
  app.post("/projects/:name/google/indexing/request", async (request) => {
50887
51310
  const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
50888
51311
  if (!googleClientId || !googleClientSecret) {
@@ -51197,7 +51620,7 @@ async function googleRoutes(app, opts) {
51197
51620
  }
51198
51621
  return response;
51199
51622
  });
51200
- app.put("/projects/:name/gbp/locations/:locationName/selection", async (request) => {
51623
+ app.put("/projects/:name/gbp/locations/:locationName/selection", connectionRoute(app, () => ({ integration: "gbp", action: "select" }), async (request) => {
51201
51624
  const project = resolveProject(app.db, request.params.name);
51202
51625
  const locationName = decodeURIComponent(request.params.locationName);
51203
51626
  const parsed = gbpLocationSelectionRequestSchema.safeParse(request.body ?? {});
@@ -51220,8 +51643,8 @@ async function googleRoutes(app, opts) {
51220
51643
  });
51221
51644
  const refreshed = app.db.select().from(gbpLocations).where(eq57(gbpLocations.id, existing.id)).get();
51222
51645
  return rowToDto2(refreshed);
51223
- });
51224
- app.delete("/projects/:name/gbp/connection", async (request, reply) => {
51646
+ }));
51647
+ app.delete("/projects/:name/gbp/connection", connectionRoute(app, () => ({ integration: "gbp", action: "disconnect" }), async (request, reply, attempt) => {
51225
51648
  const project = resolveProject(app.db, request.params.name);
51226
51649
  const store = requireConnectionStore();
51227
51650
  app.db.transaction((tx) => {
@@ -51233,9 +51656,9 @@ async function googleRoutes(app, opts) {
51233
51656
  entityType: "gbp_connection"
51234
51657
  });
51235
51658
  });
51236
- store.deleteConnection(project.canonicalDomain, "gbp");
51659
+ if (!store.deleteConnection(project.canonicalDomain, "gbp")) attempt.cancelled("NOT_CONNECTED");
51237
51660
  return reply.status(204).send();
51238
- });
51661
+ }));
51239
51662
  app.post("/projects/:name/gbp/sync", async (request) => {
51240
51663
  const project = resolveProject(app.db, request.params.name);
51241
51664
  const store = requireConnectionStore();
@@ -51500,6 +51923,10 @@ var oauthStateSchema = z4.object({
51500
51923
  }).strict();
51501
51924
  var GOOGLE_MARKETING_OAUTH_PENDING_FLOW_MAX = 256;
51502
51925
  var GOOGLE_MARKETING_OAUTH_COOKIE_PREFIX = "canonry_google_marketing_oauth_";
51926
+ var MARKETING_INTEGRATIONS = { "google-ads": "google_ads", gtm: "gtm" };
51927
+ function marketingConnection(provider, action = "connect") {
51928
+ return typeof provider === "string" && Object.hasOwn(MARKETING_INTEGRATIONS, provider) ? { integration: MARKETING_INTEGRATIONS[provider], action } : void 0;
51929
+ }
51503
51930
  function oauthInitiator(request) {
51504
51931
  const principal = request.principal;
51505
51932
  return {
@@ -52226,7 +52653,7 @@ async function googleMarketingRoutes(app, opts) {
52226
52653
  });
52227
52654
  if (stateSecret) {
52228
52655
  const registerOAuthConnect = (path8, provider) => {
52229
- app.post(path8, async (request, reply) => {
52656
+ app.post(path8, connectionRoute(app, () => marketingConnection(provider), async (request, reply, attempt) => {
52230
52657
  requireWrite(request);
52231
52658
  if (request.principal && !request.principal.viaCookie) {
52232
52659
  throw forbidden("Google Marketing OAuth must be started from a signed-in browser.");
@@ -52240,6 +52667,8 @@ async function googleMarketingRoutes(app, opts) {
52240
52667
  }
52241
52668
  const project = resolveProject(app.db, request.params.name);
52242
52669
  const store = requireCredentialStore(opts);
52670
+ const connectionAction = telemetryRead(() => store.get(asProjectRef(project), provider), void 0) ? "reauth" : "connect";
52671
+ attempt.update({ action: connectionAction });
52243
52672
  const adapter = opts.googleMarketingOAuth;
52244
52673
  if (!adapter) throw notImplemented("Google Marketing OAuth is not configured for this deployment.");
52245
52674
  const scopes = opts.googleMarketingOAuthScopes?.[provider];
@@ -52281,6 +52710,7 @@ async function googleMarketingRoutes(app, opts) {
52281
52710
  expiresAtMs: nowMs + GOOGLE_OAUTH_STATE_MAX_AGE_MS,
52282
52711
  initiator: oauthInitiator(request),
52283
52712
  browserNonce,
52713
+ connectionAction,
52284
52714
  ...suppliedDeveloperToken ? { developerToken: suppliedDeveloperToken } : {}
52285
52715
  }, nowMs);
52286
52716
  appendSetCookie(reply, serializeOAuthBindingCookie({
@@ -52297,28 +52727,34 @@ async function googleMarketingRoutes(app, opts) {
52297
52727
  entityId: provider,
52298
52728
  diff: { provider }
52299
52729
  }));
52730
+ attempt.started();
52300
52731
  return {
52301
52732
  provider,
52302
52733
  authorizationUrl,
52303
52734
  redirectUri,
52304
52735
  expiresAt: null
52305
52736
  };
52306
- });
52737
+ }));
52307
52738
  };
52308
52739
  registerOAuthConnect("/projects/:name/google-ads/oauth/connect", GoogleMarketingProviders["google-ads"]);
52309
52740
  registerOAuthConnect("/projects/:name/gtm/oauth/connect", GoogleMarketingProviders.gtm);
52310
- app.get("/google-marketing/callback", async (request, reply) => {
52741
+ app.get("/google-marketing/callback", connectionRoute(app, (request) => {
52742
+ const verified = oauthStateSchema.safeParse(verifySignedGoogleOAuthState(request.query.state ?? "", stateSecret));
52743
+ return marketingConnection(verified.success ? verified.data.provider : unverifiedOAuthStateField(request.query.state, "provider"));
52744
+ }, async (request, reply, attempt) => {
52311
52745
  if (!request.query.state) {
52312
52746
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Missing OAuth state."));
52313
52747
  }
52314
52748
  const state = verifySignedGoogleOAuthState(request.query.state, stateSecret);
52315
52749
  const parsedState = oauthStateSchema.safeParse(state);
52316
52750
  if (!parsedState.success) {
52751
+ attempt.failed(void 0, "OAUTH_STATE_INVALID");
52317
52752
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Invalid, expired, or tampered OAuth state."));
52318
52753
  }
52319
52754
  const browserNonce = parseCookieHeader(request.headers.cookie)[oauthBindingCookieName(parsedState.data.nonce)];
52320
52755
  const pending = pendingOAuthFlows.consume(parsedState.data, browserNonce);
52321
52756
  if (!pending) {
52757
+ attempt.failed(void 0, "OAUTH_STATE_INVALID");
52322
52758
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "OAuth state is unknown, expired, already used, or was opened in a different browser."));
52323
52759
  }
52324
52760
  appendSetCookie(reply, serializeOAuthBindingCookie({
@@ -52327,39 +52763,48 @@ async function googleMarketingRoutes(app, opts) {
52327
52763
  secure: new URL(parsedState.data.redirectUri).protocol === "https:",
52328
52764
  opts
52329
52765
  }));
52766
+ attempt.update({ action: pending.connectionAction ?? "connect" });
52330
52767
  if (request.query.error) {
52331
52768
  pendingOAuthFlows.finish(pending);
52769
+ if (request.query.error === "access_denied") attempt.cancelled("OAUTH_CANCELLED");
52770
+ else attempt.failed(void 0, "AUTH_DENIED");
52332
52771
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Google did not approve this authorization request."));
52333
52772
  }
52334
52773
  if (!request.query.code) {
52335
52774
  pendingOAuthFlows.finish(pending);
52775
+ attempt.failed(void 0, "VALIDATION");
52336
52776
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Missing OAuth code."));
52337
52777
  }
52338
52778
  const { projectId, projectName, provider, redirectUri } = parsedState.data;
52339
52779
  const project = app.db.select().from(projects).where(eq58(projects.id, projectId)).get();
52340
52780
  if (!project || project.name !== projectName) {
52341
52781
  pendingOAuthFlows.finish(pending);
52782
+ attempt.failed(void 0, project ? "OAUTH_STATE_INVALID" : "NOT_FOUND");
52342
52783
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Project ownership changed. Restart the OAuth flow."));
52343
52784
  }
52344
52785
  const store = opts.googleMarketingCredentialStore;
52345
52786
  const adapter = opts.googleMarketingOAuth;
52346
52787
  if (!store || !adapter) {
52347
52788
  pendingOAuthFlows.finish(pending);
52789
+ attempt.failed(void 0, "UNSUPPORTED");
52348
52790
  return reply.status(500).type("text/html").send(oauthHtml("Authorization failed", "Google Marketing OAuth is not configured."));
52349
52791
  }
52350
52792
  let tokens;
52351
52793
  try {
52352
52794
  tokens = await adapter.exchangeCode({ provider, code: request.query.code, redirectUri });
52353
- } catch {
52795
+ } catch (err) {
52354
52796
  pendingOAuthFlows.finish(pending);
52797
+ attempt.failed(err);
52355
52798
  return reply.status(502).type("text/html").send(oauthHtml("Authorization failed", "Google rejected the OAuth code. Restart the connection flow."));
52356
52799
  }
52357
52800
  if (!pendingOAuthFlows.isCurrent(pending)) {
52358
52801
  pendingOAuthFlows.finish(pending);
52802
+ attempt.failed(void 0, "OAUTH_STATE_INVALID");
52359
52803
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "This OAuth flow was replaced or disconnected. Restart the connection flow."));
52360
52804
  }
52361
52805
  if (typeof tokens.refreshToken !== "string" || tokens.refreshToken.trim().length === 0) {
52362
52806
  pendingOAuthFlows.finish(pending);
52807
+ attempt.failed(void 0, "PERMISSION_MISSING");
52363
52808
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Google did not provide offline access. Restart the connection flow and approve access."));
52364
52809
  }
52365
52810
  let confirmationId;
@@ -52367,19 +52812,22 @@ async function googleMarketingRoutes(app, opts) {
52367
52812
  confirmationId = pendingOAuthFlows.createConfirmation(pending, tokens);
52368
52813
  } catch {
52369
52814
  pendingOAuthFlows.finish(pending);
52815
+ attempt.failed(void 0, "RATE_LIMITED");
52370
52816
  return reply.status(503).type("text/html").send(oauthHtml("Authorization failed", "Too many pending OAuth confirmations. Restart the connection flow."));
52371
52817
  }
52372
52818
  if (!confirmationId) {
52373
52819
  pendingOAuthFlows.finish(pending);
52820
+ attempt.failed(void 0, "OAUTH_STATE_INVALID");
52374
52821
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "This OAuth flow was replaced or disconnected. Restart the connection flow."));
52375
52822
  }
52823
+ attempt.deferred();
52376
52824
  return reply.type("text/html").send(oauthConfirmationHtml({
52377
52825
  provider,
52378
52826
  projectName: project.name,
52379
52827
  confirmationId,
52380
52828
  opts
52381
52829
  }));
52382
- });
52830
+ }));
52383
52831
  app.post("/google-marketing/callback/confirm/:confirmationId", async (request, reply) => {
52384
52832
  if (request.principal && !request.principal.viaCookie) {
52385
52833
  throw forbidden("Google Marketing OAuth confirmation requires the browser that started the connection.");
@@ -52395,18 +52843,22 @@ async function googleMarketingRoutes(app, opts) {
52395
52843
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "This OAuth confirmation is unknown, expired, already used, or belongs to another browser session."));
52396
52844
  }
52397
52845
  const { flow, tokens } = confirmation;
52846
+ const attempt = startConnectionAttempt(app, marketingConnection(flow.provider, flow.connectionAction));
52398
52847
  if (!pendingOAuthFlows.isCurrent(flow)) {
52399
52848
  pendingOAuthFlows.finish(flow);
52849
+ attempt.failed(void 0, "OAUTH_STATE_INVALID");
52400
52850
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "This OAuth flow was replaced or disconnected. Restart the connection flow."));
52401
52851
  }
52402
52852
  const project = app.db.select().from(projects).where(eq58(projects.id, flow.projectId)).get();
52403
52853
  if (!project || project.name !== flow.projectName) {
52404
52854
  pendingOAuthFlows.finish(flow);
52855
+ attempt.failed(void 0, project ? "OAUTH_STATE_INVALID" : "NOT_FOUND");
52405
52856
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Project ownership changed. Restart the OAuth flow."));
52406
52857
  }
52407
52858
  const store = opts.googleMarketingCredentialStore;
52408
52859
  if (!store) {
52409
52860
  pendingOAuthFlows.finish(flow);
52861
+ attempt.failed(void 0, "UNSUPPORTED");
52410
52862
  return reply.status(500).type("text/html").send(oauthHtml("Authorization failed", "Google Marketing OAuth is not configured."));
52411
52863
  }
52412
52864
  const projectRef = asProjectRef(project);
@@ -52494,6 +52946,7 @@ async function googleMarketingRoutes(app, opts) {
52494
52946
  });
52495
52947
  });
52496
52948
  } catch (error) {
52949
+ attempt.failed(error);
52497
52950
  if (rollbackCredential) {
52498
52951
  try {
52499
52952
  rollbackCredential();
@@ -52505,6 +52958,7 @@ async function googleMarketingRoutes(app, opts) {
52505
52958
  } finally {
52506
52959
  pendingOAuthFlows.finish(flow);
52507
52960
  }
52961
+ attempt.succeeded();
52508
52962
  const label = flow.provider === GoogleMarketingProviders["google-ads"] ? "Google Ads" : "Google Tag Manager";
52509
52963
  return reply.type("text/html").send(oauthHtml("Connected successfully", `${label} is now linked to this project.`));
52510
52964
  });
@@ -52595,16 +53049,24 @@ async function googleMarketingRoutes(app, opts) {
52595
53049
  enqueueAfterCommit(app, callback, run.id, projectId, kind);
52596
53050
  return run.id;
52597
53051
  };
52598
- app.put("/projects/:name/google-ads/selection", async (request) => {
53052
+ app.put("/projects/:name/google-ads/selection", connectionRoute(app, () => marketingConnection(GoogleMarketingProviders["google-ads"], "select"), async (request, _reply, attempt) => {
52599
53053
  requireWrite(request);
52600
53054
  const parsedBody = parseBody3(googleAdsCustomerSelectionRequestSchema, request.body, "Google Ads selection");
52601
53055
  const body = canonicalizeGoogleAdsCustomerSelection(parsedBody);
52602
53056
  if (!body) throw validationError("Invalid Google Ads selection.");
52603
53057
  const project = resolveProject(app.db, request.params.name);
52604
53058
  const projectRef = asProjectRef(project);
52605
- requiredCredential(opts, projectRef, GoogleMarketingProviders["google-ads"]);
53059
+ try {
53060
+ requiredCredential(opts, projectRef, GoogleMarketingProviders["google-ads"]);
53061
+ } catch (err) {
53062
+ attempt.failed(err, "NOT_CONNECTED");
53063
+ throw err;
53064
+ }
52606
53065
  const existing = app.db.select().from(googleAdsConnections).where(eq58(googleAdsConnections.projectId, project.id)).get();
52607
- if (!existing) throw validationError("Connect Google Ads before selecting a customer.");
53066
+ if (!existing) {
53067
+ attempt.failed(void 0, "NOT_CONNECTED");
53068
+ throw validationError("Connect Google Ads before selecting a customer.");
53069
+ }
52608
53070
  const now = (/* @__PURE__ */ new Date()).toISOString();
52609
53071
  const customerChanged = existing.selectedCustomerId !== body.customerId;
52610
53072
  app.db.transaction((tx) => {
@@ -52637,17 +53099,25 @@ async function googleMarketingRoutes(app, opts) {
52637
53099
  });
52638
53100
  queueSelectionSync(request, project.id, "google-ads-sync", opts.onGoogleAdsSyncRequested);
52639
53101
  return googleAdsStatus(app, opts, projectRef);
52640
- });
52641
- app.put("/projects/:name/gtm/selection", async (request) => {
53102
+ }));
53103
+ app.put("/projects/:name/gtm/selection", connectionRoute(app, () => marketingConnection(GoogleMarketingProviders.gtm, "select"), async (request, _reply, attempt) => {
52642
53104
  requireWrite(request);
52643
53105
  const parsedBody = parseBody3(gtmResourceSelectionRequestSchema, request.body, "GTM selection");
52644
53106
  const body = canonicalizeGtmResourceSelection(parsedBody);
52645
53107
  if (!body) throw validationError("Invalid GTM selection.");
52646
53108
  const project = resolveProject(app.db, request.params.name);
52647
53109
  const projectRef = asProjectRef(project);
52648
- requiredCredential(opts, projectRef, GoogleMarketingProviders.gtm);
53110
+ try {
53111
+ requiredCredential(opts, projectRef, GoogleMarketingProviders.gtm);
53112
+ } catch (err) {
53113
+ attempt.failed(err, "NOT_CONNECTED");
53114
+ throw err;
53115
+ }
52649
53116
  const existing = app.db.select().from(gtmConnections).where(eq58(gtmConnections.projectId, project.id)).get();
52650
- if (!existing) throw validationError("Connect Google Tag Manager before selecting resources.");
53117
+ if (!existing) {
53118
+ attempt.failed(void 0, "NOT_CONNECTED");
53119
+ throw validationError("Connect Google Tag Manager before selecting resources.");
53120
+ }
52651
53121
  const now = (/* @__PURE__ */ new Date()).toISOString();
52652
53122
  const selectedWorkspaceId = body.workspaceId ?? null;
52653
53123
  const containerChanged = existing.selectedAccountId !== body.accountId || existing.selectedContainerId !== body.containerId;
@@ -52681,7 +53151,7 @@ async function googleMarketingRoutes(app, opts) {
52681
53151
  });
52682
53152
  queueSelectionSync(request, project.id, "gtm-sync", opts.onGtmSyncRequested);
52683
53153
  return gtmStatus(app, opts, projectRef);
52684
- });
53154
+ }));
52685
53155
  app.post("/projects/:name/google-ads/sync", async (request) => {
52686
53156
  requireLiveRead(request);
52687
53157
  requireWrite(request);
@@ -52823,7 +53293,7 @@ async function googleMarketingRoutes(app, opts) {
52823
53293
  if (!snapshot) throw providerError("Stored GTM snapshot is invalid.");
52824
53294
  return { snapshot };
52825
53295
  });
52826
- app.delete("/projects/:name/google-ads/connection", async (request) => {
53296
+ app.delete("/projects/:name/google-ads/connection", connectionRoute(app, () => marketingConnection(GoogleMarketingProviders["google-ads"], "disconnect"), async (request, _reply, attempt) => {
52827
53297
  requireWrite(request);
52828
53298
  const project = resolveProject(app.db, request.params.name);
52829
53299
  const projectRef = asProjectRef(project);
@@ -52859,9 +53329,10 @@ async function googleMarketingRoutes(app, opts) {
52859
53329
  }));
52860
53330
  });
52861
53331
  }
53332
+ if (!row && !removedCredential) attempt.cancelled("NOT_CONNECTED");
52862
53333
  return { provider: GoogleMarketingProviders["google-ads"], disconnected: Boolean(row) || removedCredential };
52863
- });
52864
- app.delete("/projects/:name/gtm/connection", async (request) => {
53334
+ }));
53335
+ app.delete("/projects/:name/gtm/connection", connectionRoute(app, () => marketingConnection(GoogleMarketingProviders.gtm, "disconnect"), async (request, _reply, attempt) => {
52865
53336
  requireWrite(request);
52866
53337
  const project = resolveProject(app.db, request.params.name);
52867
53338
  const projectRef = asProjectRef(project);
@@ -52895,8 +53366,9 @@ async function googleMarketingRoutes(app, opts) {
52895
53366
  }));
52896
53367
  });
52897
53368
  }
53369
+ if (!row && !removedCredential) attempt.cancelled("NOT_CONNECTED");
52898
53370
  return { provider: GoogleMarketingProviders.gtm, disconnected: Boolean(row) || removedCredential };
52899
- });
53371
+ }));
52900
53372
  app.get("/projects/:name/conversion-tracking/options", async (request) => {
52901
53373
  const project = resolveProject(app.db, request.params.name);
52902
53374
  const adsSnapshot = latestGoogleAdsSnapshot(app, project.id);
@@ -54912,6 +55384,31 @@ function enforceAdsActivationOperationalCap(manifest) {
54912
55384
  );
54913
55385
  }
54914
55386
  }
55387
+ var ACTIVATION_FAILURE_REASONS = {
55388
+ [AdsActivationErrorCodes.invalidManifest]: OutcomeReasonCodes.VALIDATION,
55389
+ [AdsActivationErrorCodes.grantNotFound]: OutcomeReasonCodes.NOT_FOUND,
55390
+ [AdsActivationErrorCodes.grantProjectMismatch]: OutcomeReasonCodes.GATE_REFUSED,
55391
+ [AdsActivationErrorCodes.grantAccountMismatch]: OutcomeReasonCodes.GATE_REFUSED,
55392
+ [AdsActivationErrorCodes.grantHashMismatch]: OutcomeReasonCodes.GATE_REFUSED,
55393
+ [AdsActivationErrorCodes.grantExecutorMismatch]: OutcomeReasonCodes.GATE_REFUSED,
55394
+ [AdsActivationErrorCodes.grantExpired]: OutcomeReasonCodes.GATE_REFUSED,
55395
+ [AdsActivationErrorCodes.grantRevoked]: OutcomeReasonCodes.CANCELLED_BY_USER,
55396
+ [AdsActivationErrorCodes.grantUsed]: OutcomeReasonCodes.GATE_REFUSED,
55397
+ [AdsActivationErrorCodes.operationConflict]: OutcomeReasonCodes.VALIDATION,
55398
+ [AdsActivationErrorCodes.accountNotApproved]: OutcomeReasonCodes.GATE_REFUSED,
55399
+ [AdsActivationErrorCodes.entityMismatch]: OutcomeReasonCodes.VALIDATION,
55400
+ [AdsActivationErrorCodes.entityNotPaused]: OutcomeReasonCodes.VALIDATION,
55401
+ [AdsActivationErrorCodes.entityStale]: OutcomeReasonCodes.VALIDATION,
55402
+ [AdsActivationErrorCodes.adNotApproved]: OutcomeReasonCodes.GATE_REFUSED,
55403
+ [AdsActivationErrorCodes.manualRemediationRequired]: OutcomeReasonCodes.QUARANTINED,
55404
+ [AdsActivationErrorCodes.persistenceFailed]: OutcomeReasonCodes.INTERNAL
55405
+ };
55406
+ function activationFailure(error) {
55407
+ const known = error instanceof AdsActivationError ? ACTIVATION_FAILURE_REASONS[error.code] : void 0;
55408
+ const failure = known ? { ...classifyOutcomeError(error), reasonCode: known } : upstreamFailure(error);
55409
+ const status = failure.reasonCode === OutcomeReasonCodes.QUARANTINED ? OutcomeStatuses.partial : failure.reasonCode === OutcomeReasonCodes.CANCELLED_BY_USER ? OutcomeStatuses.cancelled : OutcomeStatuses.failed;
55410
+ return { status, ...failure };
55411
+ }
54915
55412
  function requireAuthenticatedKey(request) {
54916
55413
  if (!request.apiKey) {
54917
55414
  throw forbidden("Ads activation approval and execution require an authenticated API key");
@@ -54987,21 +55484,39 @@ function terminalActivationResponse(app, opts, grant, operationKey) {
54987
55484
  return activationResponse(opts, canonical.grant, canonical.operation);
54988
55485
  }
54989
55486
  async function executeActivationGrant(app, opts, request, project, executorApiKeyId, grant, operationKey) {
54990
- const runtime = await opts.resolveRuntime(project);
54991
- const result = await executeApprovedAdsActivation({
54992
- store: createActivationStore(app, request),
54993
- provider: runtime.provider,
54994
- leaseMs: opts.leaseMs ?? ACTIVATION_LEASE_MS
54995
- }, {
54996
- projectId: project.id,
54997
- adAccountId: runtime.adAccountId,
54998
- operationKey,
54999
- grantId: grant.id,
55000
- manifestHash: grant.manifestHash,
55001
- executorApiKeyId,
55002
- manifest: grant.manifest
55003
- });
55004
- return activationResponse(opts, result.grant, result.operation);
55487
+ const outcome2 = startRouteOutcome(app, FeatureNames.openai_ads, "activation", request ? {} : { trigger: OutcomeTriggers.retry });
55488
+ const store = createActivationStore(app, request);
55489
+ let claimKind;
55490
+ try {
55491
+ const runtime = await opts.resolveRuntime(project);
55492
+ const result = await executeApprovedAdsActivation({
55493
+ store: {
55494
+ ...store,
55495
+ claimGrantAndOperation: async (input) => {
55496
+ const claim = await store.claimGrantAndOperation(input);
55497
+ claimKind = claim.kind;
55498
+ return claim;
55499
+ }
55500
+ },
55501
+ provider: runtime.provider,
55502
+ leaseMs: opts.leaseMs ?? ACTIVATION_LEASE_MS
55503
+ }, {
55504
+ projectId: project.id,
55505
+ adAccountId: runtime.adAccountId,
55506
+ operationKey,
55507
+ grantId: grant.id,
55508
+ manifestHash: grant.manifestHash,
55509
+ executorApiKeyId,
55510
+ manifest: grant.manifest
55511
+ });
55512
+ if (claimKind === "claimed" || claimKind === "resumed") {
55513
+ outcome2.report({ status: OutcomeStatuses.succeeded, counts: { campaigns: 1, operations: result.steps.length } });
55514
+ }
55515
+ return activationResponse(opts, result.grant, result.operation);
55516
+ } catch (error) {
55517
+ outcome2.report(activationFailure(error));
55518
+ throw error;
55519
+ }
55005
55520
  }
55006
55521
  function selectWatchdogOperations(app, nowIso, batchSize, allowLiveRevocationLease) {
55007
55522
  const leaseAvailable = or15(
@@ -55356,7 +55871,8 @@ function registerAdsActivationRoutes(app, opts) {
55356
55871
  const approver = requireAuthenticatedKey(request);
55357
55872
  const project = resolveProject(app.db, request.params.name);
55358
55873
  const body = parseBody4(adsActivationGrantCreateRequestSchema, request.body);
55359
- enforceAdsActivationOperationalCap(body.manifest);
55874
+ const outcome2 = startRouteOutcome(app, FeatureNames.openai_ads, "activation");
55875
+ outcome2.guard(OutcomeReasonCodes.GATE_REFUSED, () => enforceAdsActivationOperationalCap(body.manifest));
55360
55876
  const now = /* @__PURE__ */ new Date();
55361
55877
  const expiresAt = Date.parse(body.expiresAt);
55362
55878
  if (!Number.isFinite(expiresAt) || expiresAt <= now.getTime() || expiresAt > now.getTime() + MAX_ACTIVATION_GRANT_LIFETIME_MS) {
@@ -55375,7 +55891,13 @@ function registerAdsActivationRoutes(app, opts) {
55375
55891
  if (!executor.scopes.includes("*") && !executor.scopes.includes(ADS_ACTIVATE_SCOPE)) {
55376
55892
  throw forbidden(`The executor API key requires the "${ADS_ACTIVATE_SCOPE}" scope`);
55377
55893
  }
55378
- const runtime = await opts.resolveRuntime(project);
55894
+ let runtime;
55895
+ try {
55896
+ runtime = await opts.resolveRuntime(project);
55897
+ } catch (error) {
55898
+ outcome2.report(activationFailure(error));
55899
+ throw error;
55900
+ }
55379
55901
  let preflight;
55380
55902
  try {
55381
55903
  preflight = body.versionPolicy === AdsActivationVersionPolicies.refreshSemanticallyUnchanged ? await refreshSemanticallyUnchangedAdsActivationManifest(runtime.provider, {
@@ -55387,6 +55909,7 @@ function registerAdsActivationRoutes(app, opts) {
55387
55909
  manifest: body.manifest
55388
55910
  });
55389
55911
  } catch (error) {
55912
+ outcome2.report(activationFailure(error));
55390
55913
  if (error instanceof AdsActivationError) throw mapApprovalPreflightError(error);
55391
55914
  throw error;
55392
55915
  }
@@ -55495,6 +56018,9 @@ function registerAdsActivationRoutes(app, opts) {
55495
56018
  if (grant.revocationRequestedAt !== null && (grant.state === AdsActivationGrantStates.executing || grant.state === AdsActivationGrantStates.unknown)) {
55496
56019
  startWatchdogSweep?.();
55497
56020
  }
56021
+ if (existing.state === AdsActivationGrantStates.approved && grant.state === AdsActivationGrantStates.revoked) {
56022
+ startRouteOutcome(app, FeatureNames.openai_ads, "activation").report({ status: OutcomeStatuses.cancelled, reasonCode: OutcomeReasonCodes.CANCELLED_BY_USER });
56023
+ }
55498
56024
  return adsActivationGrantResponseSchema.parse({ grant });
55499
56025
  }
55500
56026
  );
@@ -56268,15 +56794,21 @@ function storedEntityState(row) {
56268
56794
  };
56269
56795
  }
56270
56796
  async function executeAdsOperation(app, request, input) {
56797
+ const outcome2 = startRouteOutcome(app, FeatureNames.openai_ads, "operation");
56271
56798
  const hash2 = requestHash({ kind: input.kind, entityType: input.entityType, payload: input.payload });
56272
56799
  const existing = readOperationByKey(app, input.projectId, input.operationKey);
56273
56800
  if (existing) {
56274
56801
  if (existing.requestHash !== hash2) {
56275
- throw alreadyExists("Ads operation key", input.operationKey);
56802
+ throw outcome2.refuse(OutcomeReasonCodes.VALIDATION, alreadyExists("Ads operation key", input.operationKey));
56276
56803
  }
56277
56804
  return { operation: operationDto(existing), replayed: true };
56278
56805
  }
56279
- await input.preflight?.();
56806
+ try {
56807
+ await input.preflight?.();
56808
+ } catch (err) {
56809
+ outcome2.report({ status: OutcomeStatuses.failed, ...upstreamFailure(err) });
56810
+ throw err;
56811
+ }
56280
56812
  const id = crypto39.randomUUID();
56281
56813
  const createdAt = (/* @__PURE__ */ new Date()).toISOString();
56282
56814
  const reconcileFields = input.reconciliation.fields ?? null;
@@ -56308,10 +56840,10 @@ async function executeAdsOperation(app, request, input) {
56308
56840
  eq60(adsOperations.operationKey, input.operationKey)
56309
56841
  )).get();
56310
56842
  if (!canonical) {
56311
- throw providerError("Ads operation receipt could not be claimed");
56843
+ throw outcome2.refuse(OutcomeReasonCodes.INTERNAL, providerError("Ads operation receipt could not be claimed"));
56312
56844
  }
56313
56845
  if (canonical.requestHash !== hash2) {
56314
- throw alreadyExists("Ads operation key", input.operationKey);
56846
+ throw outcome2.refuse(OutcomeReasonCodes.VALIDATION, alreadyExists("Ads operation key", input.operationKey));
56315
56847
  }
56316
56848
  if (inserted.length === 0) {
56317
56849
  return { operation: operationDto(canonical), replayed: true };
@@ -56360,6 +56892,7 @@ async function executeAdsOperation(app, request, input) {
56360
56892
  }));
56361
56893
  }
56362
56894
  });
56895
+ outcome2.report({ status: OutcomeStatuses.succeeded });
56363
56896
  const row = app.db.select().from(adsOperations).where(eq60(adsOperations.id, id)).get();
56364
56897
  return { operation: operationDto(row), replayed: false };
56365
56898
  } catch (err) {
@@ -56390,6 +56923,10 @@ async function executeAdsOperation(app, request, input) {
56390
56923
  }
56391
56924
  return update.changes > 0;
56392
56925
  });
56926
+ outcome2.report({
56927
+ status: failure.state === AdsOperationStates.failed ? OutcomeStatuses.failed : OutcomeStatuses.partial,
56928
+ ...upstreamFailure(err)
56929
+ });
56393
56930
  if (!finalized) {
56394
56931
  const canonical2 = app.db.select().from(adsOperations).where(and50(
56395
56932
  eq60(adsOperations.id, id),
@@ -56605,8 +57142,17 @@ async function reconcileOneAdsOperation(app, input) {
56605
57142
  maxAttempts: Math.min(20, Math.max(1, input.maxAttempts ?? DEFAULT_RECONCILE_MAX_ATTEMPTS))
56606
57143
  };
56607
57144
  const context = input.audit ?? { actor: "system" };
57145
+ const outcome2 = startRouteOutcome(app, FeatureNames.openai_ads, "reconcile", context.request ? {} : { trigger: OutcomeTriggers.scheduled });
57146
+ const reportInProgress = () => outcome2.report({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.OPERATION_IN_PROGRESS });
57147
+ const finished = (row, failure) => {
57148
+ if (row.state === AdsOperationStates.succeeded) outcome2.report({ status: OutcomeStatuses.succeeded });
57149
+ else if (row.errorCode === ADS_RECONCILIATION_QUARANTINED) outcome2.report({ status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.QUARANTINED });
57150
+ else outcome2.report({ status: OutcomeStatuses.failed, ...failure });
57151
+ return reconcileResponse(row);
57152
+ };
56608
57153
  const pendingWaitMs = existing.state === AdsOperationStates.pending ? pendingIdleRemainingMs(existing, now, policy.pendingMinIdleMs) : 0;
56609
57154
  if (pendingWaitMs > 0) {
57155
+ reportInProgress();
56610
57156
  throw operationInProgress(
56611
57157
  "The ads mutation may still be in flight; reconciliation is blocked until the receipt is idle",
56612
57158
  {
@@ -56617,7 +57163,14 @@ async function reconcileOneAdsOperation(app, input) {
56617
57163
  );
56618
57164
  }
56619
57165
  if (existing.reconcileAttempts >= policy.maxAttempts) {
56620
- return reconcileResponse(quarantineExhaustedReconciliation(app, existing, now, policy, context));
57166
+ const row = quarantineExhaustedReconciliation(app, existing, now, policy, context);
57167
+ if (row.errorCode !== ADS_RECONCILIATION_QUARANTINED) {
57168
+ reportInProgress();
57169
+ } else {
57170
+ const already = existing.errorCode === ADS_RECONCILIATION_QUARANTINED;
57171
+ outcome2.report({ status: already ? OutcomeStatuses.skipped : OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.QUARANTINED });
57172
+ }
57173
+ return reconcileResponse(row);
56621
57174
  }
56622
57175
  const leaseOwner = `ads-reconcile:${crypto39.randomUUID()}`;
56623
57176
  const claimed = claimOperationForReconciliation(
@@ -56635,6 +57188,7 @@ async function reconcileOneAdsOperation(app, input) {
56635
57188
  if (canonical.state === AdsOperationStates.pending) {
56636
57189
  const retryAfterMs = pendingIdleRemainingMs(canonical, now, policy.pendingMinIdleMs);
56637
57190
  if (retryAfterMs > 0) {
57191
+ reportInProgress();
56638
57192
  throw operationInProgress(
56639
57193
  "The ads mutation may still be in flight; reconciliation is blocked until the receipt is idle",
56640
57194
  {
@@ -56645,6 +57199,7 @@ async function reconcileOneAdsOperation(app, input) {
56645
57199
  );
56646
57200
  }
56647
57201
  }
57202
+ reportInProgress();
56648
57203
  return {
56649
57204
  operation: operationDto(canonical),
56650
57205
  resolved: canonical.state === AdsOperationStates.succeeded
@@ -56660,7 +57215,9 @@ async function reconcileOneAdsOperation(app, input) {
56660
57215
  claimed.adAccountId ? "The current credential is connected to a different OpenAI ad account" : "This legacy receipt is not bound to a verified OpenAI ad account",
56661
57216
  policy.maxAttempts
56662
57217
  );
56663
- return reconcileResponse(row);
57218
+ return finished(row, {
57219
+ reasonCode: claimed.adAccountId ? OutcomeReasonCodes.ACCOUNT_NOT_FOUND : OutcomeReasonCodes.UNSUPPORTED
57220
+ });
56664
57221
  }
56665
57222
  const strategy = claimed.reconcileStrategy;
56666
57223
  const desired = claimed.reconcileFields;
@@ -56674,7 +57231,7 @@ async function reconcileOneAdsOperation(app, input) {
56674
57231
  "This operation cannot be reconciled automatically",
56675
57232
  policy.maxAttempts
56676
57233
  );
56677
- return reconcileResponse(row);
57234
+ return finished(row, { reasonCode: OutcomeReasonCodes.UNSUPPORTED });
56678
57235
  }
56679
57236
  try {
56680
57237
  let match;
@@ -56689,7 +57246,7 @@ async function reconcileOneAdsOperation(app, input) {
56689
57246
  "The create outcome has no checkpointed provider id and cannot be resolved automatically",
56690
57247
  policy.maxAttempts
56691
57248
  );
56692
- return reconcileResponse(row2);
57249
+ return finished(row2, { reasonCode: OutcomeReasonCodes.UNSUPPORTED });
56693
57250
  }
56694
57251
  if (checkpointedId) {
56695
57252
  const entity = await getEntityForReconciliation(
@@ -56718,13 +57275,13 @@ async function reconcileOneAdsOperation(app, input) {
56718
57275
  "The upstream entity did not match the requested safe state",
56719
57276
  policy.maxAttempts
56720
57277
  );
56721
- return reconcileResponse(row2);
57278
+ return finished(row2, { reasonCode: OutcomeReasonCodes.UNKNOWN });
56722
57279
  }
56723
57280
  const row = finishReconciliation(app, claimed, leaseOwner, context, {
56724
57281
  state: AdsOperationStates.succeeded,
56725
57282
  entity: match
56726
57283
  }, policy.maxAttempts);
56727
- return reconcileResponse(row);
57284
+ return finished(row, { reasonCode: OutcomeReasonCodes.UNKNOWN });
56728
57285
  } catch (error) {
56729
57286
  const failure = errorDetails(error);
56730
57287
  const row = unknownReconciliation(
@@ -56736,7 +57293,7 @@ async function reconcileOneAdsOperation(app, input) {
56736
57293
  "OpenAI Ads API outcome could not be reconciled",
56737
57294
  policy.maxAttempts
56738
57295
  );
56739
- return reconcileResponse(row);
57296
+ return finished(row, upstreamFailure(error));
56740
57297
  }
56741
57298
  }
56742
57299
  async function sweepAdsOperationsOnce(app, opts, config) {
@@ -56808,7 +57365,10 @@ async function sweepAdsOperationsOnce(app, opts, config) {
56808
57365
  )
56809
57366
  )).orderBy(asc12(adsOperations.updatedAt)).limit(config.batchSize).all();
56810
57367
  for (const row of exhaustedRows) {
56811
- quarantineExhaustedReconciliation(app, row, now, config.policy, { actor: "system" });
57368
+ const settled = quarantineExhaustedReconciliation(app, row, now, config.policy, { actor: "system" });
57369
+ if (settled.errorCode === ADS_RECONCILIATION_QUARANTINED) {
57370
+ startRouteOutcome(app, FeatureNames.openai_ads, "reconcile", { trigger: OutcomeTriggers.scheduled }).report({ status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.QUARANTINED });
57371
+ }
56812
57372
  }
56813
57373
  const credentialsByProjectId = /* @__PURE__ */ new Map();
56814
57374
  const projectRows = app.db.selectDistinct({ id: projects.id, name: projects.name }).from(projects).innerJoin(adsOperations, eq60(adsOperations.projectId, projects.id)).where(staleReconcileWhere).all();
@@ -57002,7 +57562,7 @@ async function adsRoutes(app, opts) {
57002
57562
  });
57003
57563
  app.post(
57004
57564
  "/projects/:name/ads/connect",
57005
- async (request) => {
57565
+ connectionRoute(app, () => ({ integration: "openai_ads", action: "connect" }), async (request, _reply, attempt) => {
57006
57566
  requireScope(request, ADS_WRITE_SCOPE);
57007
57567
  const project = resolveProject(app.db, request.params.name);
57008
57568
  const parsed = adsConnectRequestSchema.safeParse(request.body);
@@ -57014,12 +57574,14 @@ async function adsRoutes(app, opts) {
57014
57574
  try {
57015
57575
  account = await opts.verifyAdsAccount(parsed.data.apiKey);
57016
57576
  } catch (err) {
57577
+ attempt.failed(err);
57017
57578
  const message = describeError(err);
57018
57579
  throw validationError(`OpenAI Ads API rejected the key: ${message}`);
57019
57580
  }
57020
57581
  const now = (/* @__PURE__ */ new Date()).toISOString();
57021
57582
  verificationCache.delete(project.id);
57022
57583
  const existingCfg = opts.adsCredentialStore.getConnection(project.name);
57584
+ if (existingCfg) attempt.update({ action: "reauth" });
57023
57585
  opts.adsCredentialStore.upsertConnection({
57024
57586
  projectName: project.name,
57025
57587
  apiKey: parsed.data.apiKey,
@@ -57071,9 +57633,9 @@ async function adsRoutes(app, opts) {
57071
57633
  });
57072
57634
  const row = app.db.select().from(adsConnections).where(eq60(adsConnections.projectId, project.id)).get();
57073
57635
  return statusDto(row);
57074
- }
57636
+ })
57075
57637
  );
57076
- app.delete("/projects/:name/ads/connection", async (request) => {
57638
+ app.delete("/projects/:name/ads/connection", connectionRoute(app, () => ({ integration: "openai_ads", action: "disconnect" }), async (request, _reply, attempt) => {
57077
57639
  requireScope(request, ADS_WRITE_SCOPE);
57078
57640
  const project = resolveProject(app.db, request.params.name);
57079
57641
  const row = app.db.select().from(adsConnections).where(eq60(adsConnections.projectId, project.id)).get();
@@ -57092,8 +57654,9 @@ async function adsRoutes(app, opts) {
57092
57654
  const removedFromConfig = opts.adsCredentialStore?.removeConnection(project.name) ?? false;
57093
57655
  verificationCache.delete(project.id);
57094
57656
  const response = { disconnected: Boolean(row) || removedFromConfig };
57657
+ if (!response.disconnected) attempt.cancelled("NOT_CONNECTED");
57095
57658
  return response;
57096
- });
57659
+ }));
57097
57660
  app.get("/projects/:name/ads/status", async (request) => {
57098
57661
  const project = resolveProject(app.db, request.params.name);
57099
57662
  const row = app.db.select().from(adsConnections).where(eq60(adsConnections.projectId, project.id)).get();
@@ -58426,7 +58989,7 @@ async function bingRoutes(app, opts) {
58426
58989
  }
58427
58990
  return conn;
58428
58991
  }
58429
- app.post("/projects/:name/bing/connect", async (request) => {
58992
+ app.post("/projects/:name/bing/connect", connectionRoute(app, () => ({ integration: "bing", action: "connect" }), async (request, _reply, attempt) => {
58430
58993
  const store = requireConnectionStore();
58431
58994
  const { apiKey } = request.body ?? {};
58432
58995
  if (!apiKey || typeof apiKey !== "string") {
@@ -58440,11 +59003,14 @@ async function bingRoutes(app, opts) {
58440
59003
  } catch (e) {
58441
59004
  const msg = describeError(e);
58442
59005
  bingLog("error", "connect.verify-key-failed", { domain: project.canonicalDomain, error: msg });
59006
+ attempt.failed(e);
58443
59007
  throw validationError(`Failed to verify Bing API key: ${msg}`);
58444
59008
  }
58445
59009
  const now = (/* @__PURE__ */ new Date()).toISOString();
58446
59010
  const existing = store.getConnection(project.canonicalDomain);
59011
+ if (existing) attempt.update({ action: "reauth" });
58447
59012
  if (existing && existing.createdByProjectId && existing.createdByProjectId !== project.id) {
59013
+ attempt.failed(void 0, "ALREADY_CONNECTED");
58448
59014
  throw validationError(
58449
59015
  `This domain already has a Bing connection owned by another project. Disconnect it from that project first before re-connecting from "${project.name}".`
58450
59016
  );
@@ -58470,12 +59036,13 @@ async function bingRoutes(app, opts) {
58470
59036
  siteUrl: existing?.siteUrl ?? null,
58471
59037
  availableSites: sites.map((s) => ({ url: s.Url, verified: s.IsVerified ?? false }))
58472
59038
  };
58473
- });
58474
- app.delete("/projects/:name/bing/disconnect", async (request, reply) => {
59039
+ }));
59040
+ app.delete("/projects/:name/bing/disconnect", connectionRoute(app, () => ({ integration: "bing", action: "disconnect" }), async (request, reply, attempt) => {
58475
59041
  const store = requireConnectionStore();
58476
59042
  const project = resolveProject(app.db, request.params.name);
58477
59043
  const existing = store.getConnection(project.canonicalDomain);
58478
59044
  if (!existing) {
59045
+ attempt.failed(void 0, "NOT_CONNECTED");
58479
59046
  throw notFound("Bing connection", project.canonicalDomain);
58480
59047
  }
58481
59048
  if (existing.createdByProjectId && existing.createdByProjectId !== project.id) {
@@ -58485,6 +59052,7 @@ async function bingRoutes(app, opts) {
58485
59052
  }
58486
59053
  const deleted = store.deleteConnection(project.canonicalDomain);
58487
59054
  if (!deleted) {
59055
+ attempt.failed(void 0, "NOT_CONNECTED");
58488
59056
  throw notFound("Bing connection", project.canonicalDomain);
58489
59057
  }
58490
59058
  writeAuditLog(app.db, {
@@ -58495,7 +59063,7 @@ async function bingRoutes(app, opts) {
58495
59063
  entityId: project.canonicalDomain
58496
59064
  });
58497
59065
  return reply.status(204).send();
58498
- });
59066
+ }));
58499
59067
  app.get("/projects/:name/bing/status", async (request) => {
58500
59068
  const store = requireConnectionStore();
58501
59069
  const project = resolveProject(app.db, request.params.name);
@@ -58516,10 +59084,15 @@ async function bingRoutes(app, opts) {
58516
59084
  const sites = await getSites(conn.apiKey);
58517
59085
  return { sites: sites.map((s) => ({ url: s.Url, verified: s.IsVerified ?? false })) };
58518
59086
  });
58519
- app.post("/projects/:name/bing/set-site", async (request) => {
59087
+ app.post("/projects/:name/bing/set-site", connectionRoute(app, () => ({ integration: "bing", action: "select" }), async (request, _reply, attempt) => {
58520
59088
  const store = requireConnectionStore();
58521
59089
  const project = resolveProject(app.db, request.params.name);
58522
- requireConnection(store, project.canonicalDomain);
59090
+ try {
59091
+ requireConnection(store, project.canonicalDomain);
59092
+ } catch (err) {
59093
+ attempt.failed(err, "NOT_CONNECTED");
59094
+ throw err;
59095
+ }
58523
59096
  const { siteUrl } = request.body ?? {};
58524
59097
  if (!siteUrl || typeof siteUrl !== "string") {
58525
59098
  throw validationError("siteUrl is required");
@@ -58529,7 +59102,7 @@ async function bingRoutes(app, opts) {
58529
59102
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
58530
59103
  });
58531
59104
  return { siteUrl };
58532
- });
59105
+ }));
58533
59106
  app.get("/projects/:name/bing/coverage", async (request) => {
58534
59107
  const store = requireConnectionStore();
58535
59108
  const project = resolveProject(app.db, request.params.name);
@@ -58653,9 +59226,13 @@ async function bingRoutes(app, opts) {
58653
59226
  app.post("/projects/:name/bing/inspect-url", async (request) => {
58654
59227
  const store = requireConnectionStore();
58655
59228
  const project = resolveProject(app.db, request.params.name);
58656
- const conn = requireConnection(store, project.canonicalDomain);
59229
+ const outcome2 = startRouteOutcome(app, FeatureNames.bing, "inspect");
59230
+ const conn = outcome2.guard(OutcomeReasonCodes.NOT_CONNECTED, () => requireConnection(store, project.canonicalDomain));
58657
59231
  if (!conn.siteUrl) {
58658
- throw validationError('No Bing site configured. Run "canonry bing set-site <project> <url>" first.');
59232
+ throw outcome2.refuse(
59233
+ OutcomeReasonCodes.PROPERTY_NOT_FOUND,
59234
+ validationError('No Bing site configured. Run "canonry bing set-site <project> <url>" first.')
59235
+ );
58659
59236
  }
58660
59237
  const { url } = request.body ?? {};
58661
59238
  if (!url) {
@@ -58726,6 +59303,7 @@ async function bingRoutes(app, opts) {
58726
59303
  discoveryDate
58727
59304
  }).run();
58728
59305
  app.db.update(runs).set({ status: RunStatuses.completed, finishedAt: now }).where(eq61(runs.id, runId)).run();
59306
+ outcome2.report({ status: OutcomeStatuses.succeeded, counts: { urls: 1 } });
58729
59307
  return {
58730
59308
  id,
58731
59309
  url,
@@ -58742,6 +59320,12 @@ async function bingRoutes(app, opts) {
58742
59320
  const msg = describeError(e);
58743
59321
  bingLog("error", "inspect-url.failed", { domain: project.canonicalDomain, url, error: msg });
58744
59322
  app.db.update(runs).set({ status: RunStatuses.failed, error: msg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq61(runs.id, runId)).run();
59323
+ const failure = classifyOutcomeError(e);
59324
+ outcome2.report({
59325
+ status: OutcomeStatuses.failed,
59326
+ ...failure,
59327
+ ...e instanceof BingApiError && e.isThrottle ? { reasonCode: OutcomeReasonCodes.RATE_LIMITED } : {}
59328
+ });
58745
59329
  throw e;
58746
59330
  }
58747
59331
  });
@@ -58893,35 +59477,48 @@ async function cdpRoutes(app, opts) {
58893
59477
  const stream = fs.createReadStream(fullPath);
58894
59478
  return reply.type("image/png").send(stream);
58895
59479
  });
58896
- app.put("/settings/cdp", async (request, reply) => {
59480
+ app.put("/settings/cdp", connectionRoute(app, () => ({ integration: "cdp", action: "connect" }), async (request, reply, attempt) => {
58897
59481
  requireScope(request, SETTINGS_WRITE_SCOPE);
58898
59482
  if (!opts.onCdpConfigure) {
58899
59483
  const err = notImplemented("CDP configuration not supported in this deployment");
59484
+ attempt.failed(err);
58900
59485
  return reply.code(err.statusCode).send(err.toJSON());
58901
59486
  }
58902
59487
  const { host, port = 9222 } = request.body;
58903
59488
  if (!host || typeof host !== "string") {
58904
59489
  const err = validationError("host is required");
59490
+ attempt.failed(err);
58905
59491
  return reply.code(err.statusCode).send(err.toJSON());
58906
59492
  }
58907
59493
  const ALLOWED_HOSTS = ["localhost", "127.0.0.1", "::1"];
58908
59494
  if (!ALLOWED_HOSTS.includes(host)) {
58909
59495
  const err = validationError("host must be localhost, 127.0.0.1, or ::1");
59496
+ attempt.failed(err, "BLOCKED_UNSAFE_URL");
58910
59497
  return reply.code(err.statusCode).send(err.toJSON());
58911
59498
  }
58912
59499
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
58913
59500
  const err = validationError("port must be an integer between 1 and 65535");
59501
+ attempt.failed(err);
58914
59502
  return reply.code(err.statusCode).send(err.toJSON());
58915
59503
  }
58916
59504
  await opts.onCdpConfigure(host, port);
58917
59505
  return reply.code(200).send({ endpoint: `ws://${host}:${port}` });
58918
- });
59506
+ }));
58919
59507
  app.get("/cdp/status", async (_request, reply) => {
58920
59508
  if (!opts.getCdpStatus) {
58921
59509
  const err = notImplemented("CDP not configured");
58922
59510
  return reply.code(err.statusCode).send(err.toJSON());
58923
59511
  }
58924
- const status = await opts.getCdpStatus();
59512
+ const attempt = startConnectionAttempt(app, { integration: "cdp", action: "test" });
59513
+ let status;
59514
+ try {
59515
+ status = await opts.getCdpStatus();
59516
+ } catch (err) {
59517
+ attempt.failed(err);
59518
+ throw err;
59519
+ }
59520
+ if (status.connected) attempt.succeeded();
59521
+ else if (status.endpoint) attempt.failed(void 0, "NETWORK");
58925
59522
  return reply.send(status);
58926
59523
  });
58927
59524
  app.post("/cdp/screenshot", async (request, reply) => {
@@ -59546,6 +60143,17 @@ async function resolveGa4AccessToken(opts, projectName, canonicalDomain) {
59546
60143
  });
59547
60144
  return { accessToken, propertyId: oauthConn.propertyId };
59548
60145
  }
60146
+ function ga4SyncFailure(err, credentialsResolved, opts, project) {
60147
+ const classified = classifyOutcomeError(err);
60148
+ if (credentialsResolved) {
60149
+ return err instanceof GA4ApiError && err.status === 404 ? { ...classified, reasonCode: OutcomeReasonCodes.PROPERTY_NOT_FOUND } : classified;
60150
+ }
60151
+ if (!(err instanceof AppError)) return credentialFailure(err);
60152
+ const oauth = opts.googleConnectionStore?.getConnection(project.canonicalDomain, "ga4");
60153
+ const serviceAccount = opts.ga4CredentialStore?.getConnection(project.name);
60154
+ const unselected = Boolean(oauth?.refreshToken && !oauth.propertyId) || Boolean(serviceAccount?.clientEmail && !serviceAccount.propertyId);
60155
+ return { ...classified, reasonCode: unselected ? OutcomeReasonCodes.PROPERTY_NOT_FOUND : OutcomeReasonCodes.NOT_CONNECTED };
60156
+ }
59549
60157
  function requireGa4Connection(opts, projectName, canonicalDomain) {
59550
60158
  const saConn = opts.ga4CredentialStore?.getConnection(projectName);
59551
60159
  const oauthConn = opts.googleConnectionStore?.getConnection(canonicalDomain, "ga4");
@@ -59554,7 +60162,10 @@ function requireGa4Connection(opts, projectName, canonicalDomain) {
59554
60162
  }
59555
60163
  }
59556
60164
  async function ga4Routes(app, opts) {
59557
- app.post("/projects/:name/ga/connect", async (request, _reply) => {
60165
+ app.post("/projects/:name/ga/connect", connectionRoute(app, (request) => {
60166
+ const body = request.body;
60167
+ return { integration: "ga4", action: typeof body?.keyJson === "string" && body.keyJson ? "connect" : "select" };
60168
+ }, async (request, _reply, attempt) => {
59558
60169
  const project = resolveProject(app.db, request.params.name);
59559
60170
  const { propertyId, keyJson } = request.body ?? {};
59560
60171
  if (!propertyId || typeof propertyId !== "string") {
@@ -59581,10 +60192,12 @@ async function ga4Routes(app, opts) {
59581
60192
  } catch (e) {
59582
60193
  const msg = describeError(e);
59583
60194
  gaLog("error", "connect.verify-failed", { projectId: project.id, propertyId, error: msg });
60195
+ attempt.failed(e);
59584
60196
  throw validationError(`Failed to verify GA4 credentials: ${msg}`);
59585
60197
  }
59586
60198
  const now = (/* @__PURE__ */ new Date()).toISOString();
59587
60199
  const existing = opts.ga4CredentialStore.getConnection(project.name);
60200
+ if (existing) attempt.update({ action: "reauth" });
59588
60201
  const previousPropertyId2 = resolveCurrentGa4PropertyId(opts, project.name, project.canonicalDomain);
59589
60202
  opts.ga4CredentialStore.upsertConnection({
59590
60203
  projectName: project.name,
@@ -59616,6 +60229,7 @@ async function ga4Routes(app, opts) {
59616
60229
  }
59617
60230
  const oauthConn = googleStore.getConnection(project.canonicalDomain, "ga4");
59618
60231
  if (!oauthConn?.accessToken || !oauthConn?.refreshToken) {
60232
+ attempt.failed(void 0, "NOT_CONNECTED");
59619
60233
  throw validationError(
59620
60234
  'No GA4 OAuth token found. Run "canonry google connect <project> --type ga4" first, or pass --key-file to use a service account.'
59621
60235
  );
@@ -59631,6 +60245,7 @@ async function ga4Routes(app, opts) {
59631
60245
  } catch (e) {
59632
60246
  const msg = describeError(e);
59633
60247
  gaLog("error", "connect.verify-failed.oauth", { projectId: project.id, propertyId, error: msg });
60248
+ attempt.failed(e);
59634
60249
  throw validationError(`Failed to verify GA4 access: ${msg}`);
59635
60250
  }
59636
60251
  const previousPropertyId = resolveCurrentGa4PropertyId(opts, project.name, project.canonicalDomain);
@@ -59651,12 +60266,13 @@ async function ga4Routes(app, opts) {
59651
60266
  entityId: propertyId
59652
60267
  });
59653
60268
  return { connected: true, propertyId, authMethod: "oauth" };
59654
- });
59655
- app.delete("/projects/:name/ga/disconnect", async (request, reply) => {
60269
+ }));
60270
+ app.delete("/projects/:name/ga/disconnect", connectionRoute(app, () => ({ integration: "ga4", action: "disconnect" }), async (request, reply, attempt) => {
59656
60271
  const project = resolveProject(app.db, request.params.name);
59657
60272
  const saConn = opts.ga4CredentialStore?.getConnection(project.name);
59658
60273
  const oauthConn = opts.googleConnectionStore?.getConnection(project.canonicalDomain, "ga4");
59659
60274
  if (!saConn && !oauthConn) {
60275
+ attempt.failed(void 0, "NOT_CONNECTED");
59660
60276
  throw notFound("GA4 connection", project.name);
59661
60277
  }
59662
60278
  app.db.delete(gaTrafficSnapshots).where(eq64(gaTrafficSnapshots.projectId, project.id)).run();
@@ -59676,7 +60292,7 @@ async function ga4Routes(app, opts) {
59676
60292
  entityId: propertyId
59677
60293
  });
59678
60294
  return reply.status(204).send();
59679
- });
60295
+ }));
59680
60296
  app.get("/projects/:name/ga/properties", async (request) => {
59681
60297
  assertNotProjectScoped(request, "listing GA4 properties");
59682
60298
  const project = resolveProject(app.db, request.params.name);
@@ -59750,8 +60366,12 @@ async function ga4Routes(app, opts) {
59750
60366
  startedAt,
59751
60367
  createdAt: startedAt
59752
60368
  }).run();
60369
+ const outcome2 = startRouteOutcome(app, FeatureNames.ga4, "sync");
60370
+ let credentialsResolved = false;
60371
+ let componentFailure;
59753
60372
  try {
59754
60373
  const { accessToken, propertyId } = await resolveGa4AccessToken(opts, project.name, project.canonicalDomain);
60374
+ credentialsResolved = true;
59755
60375
  let rows = [];
59756
60376
  let aiReferrals = [];
59757
60377
  let socialReferrals = [];
@@ -59924,6 +60544,7 @@ async function ga4Routes(app, opts) {
59924
60544
  const [acquisitionSettled, leadsSettled, searchLandingSettled] = await measurementFetch;
59925
60545
  const measurementNow = (/* @__PURE__ */ new Date()).toISOString();
59926
60546
  const recordMeasurementFailure = (component, reason) => {
60547
+ componentFailure ??= { error: reason };
59927
60548
  const message = describeError(reason);
59928
60549
  gaLog("warn", "measurement.component-failed", {
59929
60550
  projectId: project.id,
@@ -59989,6 +60610,7 @@ async function ga4Routes(app, opts) {
59989
60610
  }
59990
60611
  })();
59991
60612
  const recordSearchLandingFailure = (status, reason) => {
60613
+ if (status === "error") componentFailure ??= { error: reason };
59992
60614
  const message = describeError(reason);
59993
60615
  gaLog("warn", "search-landing.component-failed", {
59994
60616
  projectId: project.id,
@@ -60047,6 +60669,14 @@ async function ga4Routes(app, opts) {
60047
60669
  totalUsers: summary3.totalUsers,
60048
60670
  ...only ? { only } : {}
60049
60671
  });
60672
+ const counts2 = { rows: rows.length + dailyTotals.length + aiReferrals.length + socialReferrals.length };
60673
+ if (componentFailure) {
60674
+ outcome2.report({ status: OutcomeStatuses.partial, ...classifyOutcomeError(componentFailure.error), counts: counts2 });
60675
+ } else if (rows.length === 0 && dailyTotals.length === 0) {
60676
+ outcome2.report({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.NO_DATA, counts: counts2 });
60677
+ } else {
60678
+ outcome2.report({ status: OutcomeStatuses.succeeded, counts: counts2 });
60679
+ }
60050
60680
  return {
60051
60681
  synced: true,
60052
60682
  rowCount: rows.length,
@@ -60067,6 +60697,7 @@ async function ga4Routes(app, opts) {
60067
60697
  const msg = describeError(e);
60068
60698
  gaLog("error", "sync.fetch-failed", { projectId: project.id, runId, error: msg });
60069
60699
  app.db.update(runs).set({ status: RunStatuses.failed, error: msg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq64(runs.id, runId)).run();
60700
+ outcome2.report({ status: OutcomeStatuses.failed, ...ga4SyncFailure(e, credentialsResolved, opts, project) });
60070
60701
  throw e;
60071
60702
  }
60072
60703
  });
@@ -61706,16 +62337,43 @@ function toAppError(error) {
61706
62337
  return providerError(error.message, { statusCode: error.statusCode });
61707
62338
  }
61708
62339
  }
61709
- async function withWordpressErrorHandling(handler) {
62340
+ function wordpressFailureReason(error) {
62341
+ if (error instanceof EgressRefusedError) return "BLOCKED_UNSAFE_URL";
62342
+ if (error instanceof EgressFailedError) return "NETWORK";
62343
+ return void 0;
62344
+ }
62345
+ async function withWordpressErrorHandling(handler, attempt) {
61710
62346
  try {
61711
62347
  return await handler();
61712
62348
  } catch (error) {
62349
+ attempt?.failed(error, wordpressFailureReason(error));
61713
62350
  if (error instanceof WordpressApiError) throw toAppError(error);
61714
62351
  if (error instanceof EgressRefusedError && !error.redirected) throw validationError(error.message);
61715
62352
  if (error instanceof EgressRefusedError || error instanceof EgressFailedError) throw providerError(error.message);
61716
62353
  throw error;
61717
62354
  }
61718
62355
  }
62356
+ var MANUAL_ASSIST_OUTCOME = { status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.UNSUPPORTED };
62357
+ function metaWriteOutcome(results) {
62358
+ const applied = results.filter((r) => r.status === "applied").length;
62359
+ const manual = results.filter((r) => r.status === "manual").length;
62360
+ const errored = results.filter((r) => r.status === "skipped").length;
62361
+ const counts2 = { items: applied, skipped: manual, failures: errored };
62362
+ if (manual === 0 && errored === 0) return { status: OutcomeStatuses.succeeded, counts: counts2 };
62363
+ const reasonCode = manual > 0 ? OutcomeReasonCodes.UNSUPPORTED : OutcomeReasonCodes.UNKNOWN;
62364
+ if (applied > 0) return { status: OutcomeStatuses.partial, reasonCode, counts: counts2 };
62365
+ return { status: errored > 0 ? OutcomeStatuses.failed : OutcomeStatuses.skipped, reasonCode, counts: counts2 };
62366
+ }
62367
+ function schemaDeployOutcome(results) {
62368
+ const deployed = results.filter((r) => r.status === "deployed").length;
62369
+ const stripped = results.filter((r) => r.status === "stripped").length;
62370
+ const missing = results.filter((r) => r.status === "skipped").length;
62371
+ const failed = results.filter((r) => r.status === "failed").length;
62372
+ const counts2 = { items: deployed, skipped: stripped + missing, failures: failed };
62373
+ if (deployed === results.length) return { status: OutcomeStatuses.succeeded, counts: counts2 };
62374
+ const reasonCode = stripped > 0 ? OutcomeReasonCodes.PERMISSION_MISSING : failed > 0 ? OutcomeReasonCodes.UNKNOWN : OutcomeReasonCodes.NOT_FOUND;
62375
+ return { status: deployed > 0 ? OutcomeStatuses.partial : OutcomeStatuses.failed, reasonCode, counts: counts2 };
62376
+ }
61719
62377
  async function wordpressRoutes(app, opts) {
61720
62378
  const allowLoopback = opts.allowLoopbackWebhooks === true;
61721
62379
  function requireStore() {
@@ -61726,20 +62384,22 @@ async function wordpressRoutes(app, opts) {
61726
62384
  function guarded(connection) {
61727
62385
  return { ...connection, fetchImpl: egressFetch };
61728
62386
  }
61729
- async function assertWordpressUrlAllowed(rawUrl, field) {
62387
+ async function assertWordpressUrlAllowed(rawUrl, field, attempt) {
61730
62388
  const check2 = await resolveWebhookTarget(rawUrl, { allowLoopback });
61731
62389
  if (!check2.ok) {
62390
+ attempt?.failed(void 0, webhookTargetRefusalReason(check2));
61732
62391
  throw validationError(`${field} ${check2.message.replace(/^"url" /, "")}`);
61733
62392
  }
61734
62393
  }
61735
- function requireConnection(store, projectName) {
62394
+ function requireConnection(store, projectName, settle) {
61736
62395
  const connection = store.getConnection(projectName);
61737
62396
  if (!connection) {
62397
+ settle?.({ status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.NOT_CONNECTED });
61738
62398
  throw validationError(`No WordPress connection found for project "${projectName}". Run "canonry wordpress connect ${projectName}" first.`);
61739
62399
  }
61740
62400
  return guarded(connection);
61741
62401
  }
61742
- app.post("/projects/:name/wordpress/connect", async (request) => {
62402
+ app.post("/projects/:name/wordpress/connect", connectionRoute(app, () => ({ integration: "wordpress", action: "connect" }), async (request, _reply, attempt) => {
61743
62403
  return withWordpressErrorHandling(async () => {
61744
62404
  const store = requireStore();
61745
62405
  const project = resolveProject(app.db, request.params.name);
@@ -61751,10 +62411,11 @@ async function wordpressRoutes(app, opts) {
61751
62411
  if (defaultEnv === "staging" && !stagingUrl) {
61752
62412
  throw validationError('defaultEnv "staging" requires stagingUrl');
61753
62413
  }
61754
- await assertWordpressUrlAllowed(url, "url");
61755
- if (stagingUrl) await assertWordpressUrlAllowed(stagingUrl, "stagingUrl");
62414
+ await assertWordpressUrlAllowed(url, "url", attempt);
62415
+ if (stagingUrl) await assertWordpressUrlAllowed(stagingUrl, "stagingUrl", attempt);
61756
62416
  const now = (/* @__PURE__ */ new Date()).toISOString();
61757
62417
  const existing = store.getConnection(project.name);
62418
+ if (existing) attempt.update({ action: "reauth" });
61758
62419
  const nextConnection = {
61759
62420
  projectName: project.name,
61760
62421
  url,
@@ -61784,13 +62445,14 @@ async function wordpressRoutes(app, opts) {
61784
62445
  staging,
61785
62446
  adminUrl: getWpStagingAdminUrl(connection.url)
61786
62447
  };
61787
- });
61788
- });
61789
- app.delete("/projects/:name/wordpress/disconnect", async (request, reply) => {
62448
+ }, attempt);
62449
+ }));
62450
+ app.delete("/projects/:name/wordpress/disconnect", connectionRoute(app, () => ({ integration: "wordpress", action: "disconnect" }), async (request, reply, attempt) => {
61790
62451
  const store = requireStore();
61791
62452
  const project = resolveProject(app.db, request.params.name);
61792
62453
  const deleted = store.deleteConnection(project.name);
61793
62454
  if (!deleted) {
62455
+ attempt.failed(void 0, "NOT_CONNECTED");
61794
62456
  throw notFound("WordPress connection", project.name);
61795
62457
  }
61796
62458
  writeAuditLog(app.db, {
@@ -61801,7 +62463,7 @@ async function wordpressRoutes(app, opts) {
61801
62463
  entityId: project.name
61802
62464
  });
61803
62465
  return reply.status(204).send();
61804
- });
62466
+ }));
61805
62467
  app.get("/projects/:name/wordpress/status", async (request) => {
61806
62468
  const project = resolveProject(app.db, request.params.name);
61807
62469
  const stored = opts.wordpressConnectionStore?.getConnection(project.name);
@@ -61853,10 +62515,10 @@ async function wordpressRoutes(app, opts) {
61853
62515
  });
61854
62516
  });
61855
62517
  app.post("/projects/:name/wordpress/pages", async (request) => {
61856
- return withWordpressErrorHandling(async () => {
62518
+ return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "publish" }, async (settle) => {
61857
62519
  const store = requireStore();
61858
62520
  const project = resolveProject(app.db, request.params.name);
61859
- const connection = requireConnection(store, project.name);
62521
+ const connection = requireConnection(store, project.name, settle);
61860
62522
  const { title, slug, content, status } = request.body ?? {};
61861
62523
  const env = parseEnvInput(request.body?.env);
61862
62524
  if (!title || !slug || !content) {
@@ -61870,14 +62532,15 @@ async function wordpressRoutes(app, opts) {
61870
62532
  entityType: "wordpress_page",
61871
62533
  entityId: created.slug
61872
62534
  });
62535
+ settle({ status: OutcomeStatuses.succeeded, counts: { items: 1 } });
61873
62536
  return created;
61874
- });
62537
+ }, wordpressFailureReason));
61875
62538
  });
61876
62539
  app.put("/projects/:name/wordpress/page", async (request) => {
61877
- return withWordpressErrorHandling(async () => {
62540
+ return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "publish" }, async (settle) => {
61878
62541
  const store = requireStore();
61879
62542
  const project = resolveProject(app.db, request.params.name);
61880
- const connection = requireConnection(store, project.name);
62543
+ const connection = requireConnection(store, project.name, settle);
61881
62544
  const currentSlug = request.body?.currentSlug?.trim();
61882
62545
  if (!currentSlug) {
61883
62546
  throw validationError("currentSlug is required");
@@ -61896,14 +62559,15 @@ async function wordpressRoutes(app, opts) {
61896
62559
  entityType: "wordpress_page",
61897
62560
  entityId: currentSlug
61898
62561
  });
62562
+ settle({ status: OutcomeStatuses.succeeded, counts: { items: 1 } });
61899
62563
  return updated;
61900
- });
62564
+ }, wordpressFailureReason));
61901
62565
  });
61902
62566
  app.post("/projects/:name/wordpress/page/meta", async (request) => {
61903
- return withWordpressErrorHandling(async () => {
62567
+ return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "meta_write" }, async (settle) => {
61904
62568
  const store = requireStore();
61905
62569
  const project = resolveProject(app.db, request.params.name);
61906
- const connection = requireConnection(store, project.name);
62570
+ const connection = requireConnection(store, project.name, settle);
61907
62571
  const slug = request.body?.slug?.trim();
61908
62572
  if (!slug) {
61909
62573
  throw validationError("slug is required");
@@ -61921,14 +62585,15 @@ async function wordpressRoutes(app, opts) {
61921
62585
  entityType: "wordpress_page",
61922
62586
  entityId: slug
61923
62587
  });
62588
+ settle({ status: OutcomeStatuses.succeeded, counts: { items: 1 } });
61924
62589
  return updated;
61925
- });
62590
+ }, wordpressFailureReason));
61926
62591
  });
61927
62592
  app.post("/projects/:name/wordpress/pages/meta/bulk", async (request) => {
61928
- return withWordpressErrorHandling(async () => {
62593
+ return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "meta_write" }, async (settle) => {
61929
62594
  const store = requireStore();
61930
62595
  const project = resolveProject(app.db, request.params.name);
61931
- const connection = requireConnection(store, project.name);
62596
+ const connection = requireConnection(store, project.name, settle);
61932
62597
  const entries = request.body?.entries;
61933
62598
  if (!Array.isArray(entries) || entries.length === 0) {
61934
62599
  throw validationError("entries array is required and must not be empty");
@@ -61950,8 +62615,9 @@ async function wordpressRoutes(app, opts) {
61950
62615
  entityId: `bulk(${applied.map((r) => r.slug).join(",")})`
61951
62616
  });
61952
62617
  }
62618
+ settle(metaWriteOutcome(result.results));
61953
62619
  return result;
61954
- });
62620
+ }, wordpressFailureReason));
61955
62621
  });
61956
62622
  app.get("/projects/:name/wordpress/schema", async (request) => {
61957
62623
  return withWordpressErrorHandling(async () => {
@@ -61967,31 +62633,35 @@ async function wordpressRoutes(app, opts) {
61967
62633
  });
61968
62634
  });
61969
62635
  app.post("/projects/:name/wordpress/schema/manual", async (request) => {
61970
- return withWordpressErrorHandling(async () => {
62636
+ return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "schema_deploy" }, async (settle) => {
61971
62637
  const store = requireStore();
61972
62638
  const project = resolveProject(app.db, request.params.name);
61973
- const connection = requireConnection(store, project.name);
62639
+ const connection = requireConnection(store, project.name, settle);
61974
62640
  const slug = request.body?.slug?.trim();
61975
62641
  const json = request.body?.json;
61976
62642
  if (!slug || !json) {
61977
62643
  throw validationError("slug and json are required");
61978
62644
  }
61979
62645
  const env = parseEnvInput(request.body?.env);
61980
- return buildManualSchemaUpdate(connection, slug, { type: request.body?.type, json }, env);
61981
- });
62646
+ const manual = await buildManualSchemaUpdate(connection, slug, { type: request.body?.type, json }, env);
62647
+ settle(MANUAL_ASSIST_OUTCOME);
62648
+ return manual;
62649
+ }, wordpressFailureReason));
61982
62650
  });
61983
62651
  app.post("/projects/:name/wordpress/schema/deploy", async (request) => {
61984
- return withWordpressErrorHandling(async () => {
62652
+ return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "schema_deploy" }, async (settle) => {
61985
62653
  const store = requireStore();
61986
62654
  const project = resolveProject(app.db, request.params.name);
61987
- const connection = requireConnection(store, project.name);
62655
+ const connection = requireConnection(store, project.name, settle);
61988
62656
  const profile = request.body?.profile;
61989
62657
  if (!profile?.business?.name || !profile?.pages || Object.keys(profile.pages).length === 0) {
61990
62658
  throw validationError("profile with business.name and non-empty pages is required");
61991
62659
  }
61992
62660
  const env = parseEnvInput(request.body?.env);
61993
- return deploySchemaFromProfile(connection, profile, env);
61994
- });
62661
+ const deployed = await deploySchemaFromProfile(connection, profile, env);
62662
+ settle(schemaDeployOutcome(deployed.results));
62663
+ return deployed;
62664
+ }, wordpressFailureReason));
61995
62665
  });
61996
62666
  app.get("/projects/:name/wordpress/schema/status", async (request) => {
61997
62667
  return withWordpressErrorHandling(async () => {
@@ -62012,17 +62682,19 @@ async function wordpressRoutes(app, opts) {
62012
62682
  });
62013
62683
  });
62014
62684
  app.post("/projects/:name/wordpress/llms-txt/manual", async (request) => {
62015
- return withWordpressErrorHandling(async () => {
62685
+ return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "llms_txt" }, async (settle) => {
62016
62686
  const store = requireStore();
62017
62687
  const project = resolveProject(app.db, request.params.name);
62018
- const connection = requireConnection(store, project.name);
62688
+ const connection = requireConnection(store, project.name, settle);
62019
62689
  const content = request.body?.content;
62020
62690
  if (!content) {
62021
62691
  throw validationError("content is required");
62022
62692
  }
62023
62693
  const env = parseEnvInput(request.body?.env);
62024
- return buildManualLlmsTxtUpdate(connection, content, env);
62025
- });
62694
+ const manual = await buildManualLlmsTxtUpdate(connection, content, env);
62695
+ settle(MANUAL_ASSIST_OUTCOME);
62696
+ return manual;
62697
+ }, wordpressFailureReason));
62026
62698
  });
62027
62699
  app.get("/projects/:name/wordpress/audit", async (request) => {
62028
62700
  return withWordpressErrorHandling(async () => {
@@ -62060,17 +62732,20 @@ async function wordpressRoutes(app, opts) {
62060
62732
  });
62061
62733
  });
62062
62734
  app.post("/projects/:name/wordpress/staging/push", async (request) => {
62063
- return withWordpressErrorHandling(async () => {
62735
+ return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "publish" }, async (settle) => {
62064
62736
  const store = requireStore();
62065
62737
  const project = resolveProject(app.db, request.params.name);
62066
- const connection = requireConnection(store, project.name);
62738
+ const connection = requireConnection(store, project.name, settle);
62067
62739
  if (!connection.stagingUrl) {
62068
62740
  throw validationError("No staging URL configured for this project. Reconnect with --staging-url before using staging push.");
62069
62741
  }
62070
- return buildManualStagingPush(connection);
62071
- });
62742
+ const manual = await buildManualStagingPush(connection);
62743
+ settle(MANUAL_ASSIST_OUTCOME);
62744
+ return manual;
62745
+ }, wordpressFailureReason));
62072
62746
  });
62073
62747
  app.post("/projects/:name/wordpress/onboard", async (request) => {
62748
+ const attempt = startConnectionAttempt(app, { integration: "wordpress", action: "connect" });
62074
62749
  return withWordpressErrorHandling(async () => {
62075
62750
  const store = requireStore();
62076
62751
  const project = resolveProject(app.db, request.params.name);
@@ -62082,14 +62757,15 @@ async function wordpressRoutes(app, opts) {
62082
62757
  if (defaultEnv === "staging" && !stagingUrl) {
62083
62758
  throw validationError('defaultEnv "staging" requires stagingUrl');
62084
62759
  }
62085
- await assertWordpressUrlAllowed(url, "url");
62086
- if (stagingUrl) await assertWordpressUrlAllowed(stagingUrl, "stagingUrl");
62760
+ await assertWordpressUrlAllowed(url, "url", attempt);
62761
+ if (stagingUrl) await assertWordpressUrlAllowed(stagingUrl, "stagingUrl", attempt);
62087
62762
  const steps = [];
62088
62763
  let connection = null;
62089
62764
  let pageUrls = [];
62090
62765
  try {
62091
62766
  const now = (/* @__PURE__ */ new Date()).toISOString();
62092
62767
  const existing = store.getConnection(project.name);
62768
+ if (existing) attempt.update({ action: "reauth" });
62093
62769
  const nextConnection = {
62094
62770
  projectName: project.name,
62095
62771
  url,
@@ -62110,7 +62786,9 @@ async function wordpressRoutes(app, opts) {
62110
62786
  entityId: project.name
62111
62787
  });
62112
62788
  steps.push({ name: "connect", status: "completed", summary: `Connected to ${url}` });
62789
+ attempt.succeeded();
62113
62790
  } catch (err) {
62791
+ attempt.failed(err, wordpressFailureReason(err));
62114
62792
  const msg = describeError(err);
62115
62793
  steps.push({ name: "connect", status: "failed", error: msg });
62116
62794
  return { projectName: project.name, steps };
@@ -62131,6 +62809,11 @@ async function wordpressRoutes(app, opts) {
62131
62809
  steps.push({ name: "audit", status: "failed", error: msg });
62132
62810
  return { projectName: project.name, steps };
62133
62811
  }
62812
+ const metaStartedAt = Date.now();
62813
+ const reportMetaStep = (outcome2) => reportFeatureOutcome(app, { feature: "wordpress", operation: "meta_write" }, {
62814
+ ...outcome2,
62815
+ durationBucket: bucketDuration(Date.now() - metaStartedAt)
62816
+ });
62134
62817
  try {
62135
62818
  const metaEntries = [];
62136
62819
  for (const issue of auditIssues) {
@@ -62155,8 +62838,10 @@ async function wordpressRoutes(app, opts) {
62155
62838
  }
62156
62839
  if (metaEntries.length === 0) {
62157
62840
  steps.push({ name: "set-meta", status: "skipped", summary: "No pages with missing meta found" });
62841
+ reportMetaStep({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.NO_DATA });
62158
62842
  } else {
62159
62843
  const result = await bulkSetSeoMeta(connection, metaEntries);
62844
+ reportMetaStep(metaWriteOutcome(result.results));
62160
62845
  const applied = result.results.filter((r) => r.status === "applied").length;
62161
62846
  const manual = result.results.filter((r) => r.status === "manual").length;
62162
62847
  const skipped4 = result.results.filter((r) => r.status === "skipped").length;
@@ -62169,6 +62854,7 @@ async function wordpressRoutes(app, opts) {
62169
62854
  } catch (err) {
62170
62855
  const msg = describeError(err);
62171
62856
  steps.push({ name: "set-meta", status: "failed", error: msg });
62857
+ reportMetaStep(failedOutcome(err, wordpressFailureReason(err)));
62172
62858
  return { projectName: project.name, steps };
62173
62859
  }
62174
62860
  if (skipSchema || !profile) {
@@ -62178,11 +62864,18 @@ async function wordpressRoutes(app, opts) {
62178
62864
  summary: skipSchema ? "Skipped via --skip-schema" : "No --profile provided"
62179
62865
  });
62180
62866
  } else {
62867
+ const schemaStartedAt = Date.now();
62868
+ const reportSchemaStep = (outcome2) => reportFeatureOutcome(app, { feature: "wordpress", operation: "schema_deploy" }, {
62869
+ ...outcome2,
62870
+ durationBucket: bucketDuration(Date.now() - schemaStartedAt)
62871
+ });
62181
62872
  try {
62182
62873
  if (!profile.business?.name || !profile.pages || Object.keys(profile.pages).length === 0) {
62183
62874
  steps.push({ name: "schema-deploy", status: "skipped", summary: "Profile missing business.name or pages" });
62875
+ reportSchemaStep({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.VALIDATION });
62184
62876
  } else {
62185
62877
  const result = await deploySchemaFromProfile(connection, profile);
62878
+ reportSchemaStep(schemaDeployOutcome(result.results));
62186
62879
  const deployed = result.results.filter((r) => r.status === "deployed").length;
62187
62880
  const stripped = result.results.filter((r) => r.status === "stripped").length;
62188
62881
  const skipped4 = result.results.filter((r) => r.status === "skipped").length;
@@ -62195,6 +62888,7 @@ async function wordpressRoutes(app, opts) {
62195
62888
  } catch (err) {
62196
62889
  const msg = describeError(err);
62197
62890
  steps.push({ name: "schema-deploy", status: "failed", error: msg });
62891
+ reportSchemaStep(failedOutcome(err, wordpressFailureReason(err)));
62198
62892
  return { projectName: project.name, steps };
62199
62893
  }
62200
62894
  }
@@ -62262,7 +62956,7 @@ async function wordpressRoutes(app, opts) {
62262
62956
  entityId: project.name
62263
62957
  });
62264
62958
  return { projectName: project.name, steps };
62265
- });
62959
+ }, attempt);
62266
62960
  });
62267
62961
  }
62268
62962
 
@@ -62854,13 +63548,14 @@ async function backlinksRoutes(app, opts) {
62854
63548
  }
62855
63549
  return reply.send(opts.getBacklinksStatus());
62856
63550
  });
62857
- app.post("/backlinks/install", async (_request, reply) => {
63551
+ app.post("/backlinks/install", async (_request, reply) => withFeatureOutcome(app, { feature: "backlinks", operation: "install" }, async (settle) => {
62858
63552
  if (!opts.onInstallBacklinks) {
62859
63553
  throw missingDependency(BACKLINKS_UNSUPPORTED_MESSAGE);
62860
63554
  }
62861
63555
  const result = await opts.onInstallBacklinks();
63556
+ if (result.alreadyPresent) settle({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.ALREADY_CONNECTED });
62862
63557
  return reply.status(200).send(result);
62863
- });
63558
+ }, (err) => err instanceof AppError && err.code === "MISSING_DEPENDENCY" ? OutcomeReasonCodes.UNSUPPORTED : void 0));
62864
63559
  app.post("/backlinks/syncs", async (request, reply) => {
62865
63560
  let release = request.body?.release;
62866
63561
  if (!release) {
@@ -79023,6 +79718,7 @@ async function deliverViaDirectPush(env, batch) {
79023
79718
  method: 'POST',
79024
79719
  headers: {
79025
79720
  'content-type': 'application/json',
79721
+ 'user-agent': 'canonry-cloudflare-worker/' + batch.workerVersion,
79026
79722
  'Authorization': 'Bearer ' + bearerToken,
79027
79723
  'X-Canonry-Timestamp': timestamp,
79028
79724
  'X-Canonry-Signature': signature,
@@ -80481,6 +81177,12 @@ async function runBackfillTask(options) {
80481
81177
  pullForBackfill,
80482
81178
  pullErrorPrefix
80483
81179
  } = options;
81180
+ const backfillStartedAt = Date.now();
81181
+ const target = { feature: "server_traffic", operation: "backfill", attribution: currentOutcomeAttribution() };
81182
+ const reportBackfill = (outcome2) => reportFeatureOutcome(app, target, {
81183
+ ...outcome2,
81184
+ durationBucket: bucketDuration(Date.now() - backfillStartedAt)
81185
+ });
80484
81186
  const markFailed = (msg, preserveSourceState = false) => {
80485
81187
  const failedAt = (/* @__PURE__ */ new Date()).toISOString();
80486
81188
  try {
@@ -80496,6 +81198,7 @@ async function runBackfillTask(options) {
80496
81198
  };
80497
81199
  if (sourceRow2.sourceType === TrafficSourceTypes.wordpress) {
80498
81200
  markFailed("Generic WordPress replace backfill is unavailable because retained coverage is unproven. Use a retention-aware repair that declares the unrecoverable span.");
81201
+ reportBackfill({ status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.UNSUPPORTED });
80499
81202
  return;
80500
81203
  }
80501
81204
  let allEvents;
@@ -80506,12 +81209,14 @@ async function runBackfillTask(options) {
80506
81209
  `${pullErrorPrefix}: ${describeError(e)}`,
80507
81210
  e instanceof VercelRetentionClampError
80508
81211
  );
81212
+ reportBackfill(failedOutcome(e, e instanceof VercelRetentionClampError ? OutcomeReasonCodes.NO_DATA : void 0));
80509
81213
  return;
80510
81214
  }
80511
81215
  if (allEvents.length === 0) {
80512
81216
  const finishedAt2 = (/* @__PURE__ */ new Date()).toISOString();
81217
+ let emptyOutcome;
80513
81218
  try {
80514
- app.db.transaction((tx) => {
81219
+ const completed = app.db.transaction((tx) => {
80515
81220
  const latestSource = tx.select().from(trafficSources).where(eq69(trafficSources.id, sourceRow2.id)).get();
80516
81221
  const stillAuthoritative = latestSource && isAuthoritativeTrafficSource(tx, latestSource) && isSameTrafficSourceGeneration(latestSource, sourceRow2);
80517
81222
  if (stillAuthoritative) {
@@ -80522,9 +81227,13 @@ async function runBackfillTask(options) {
80522
81227
  error: "Traffic source was deactivated or reconfigured during backfill",
80523
81228
  finishedAt: finishedAt2
80524
81229
  }).where(eq69(runs.id, runId)).run();
81230
+ return Boolean(stillAuthoritative);
80525
81231
  });
80526
- } catch {
81232
+ emptyOutcome = completed ? { status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.NO_DATA } : { status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.NOT_CONNECTED };
81233
+ } catch (e) {
81234
+ emptyOutcome = failedOutcome(e);
80527
81235
  }
81236
+ reportBackfill(emptyOutcome);
80528
81237
  return;
80529
81238
  }
80530
81239
  const report = buildTrafficProbeReport(allEvents, { sampleLimit: BACKFILL_SAMPLE_LIMIT });
@@ -80784,11 +81493,30 @@ async function runBackfillTask(options) {
80784
81493
  tx.update(runs).set({ status: RunStatuses.completed, finishedAt }).where(eq69(runs.id, runId)).run();
80785
81494
  return "committed";
80786
81495
  });
81496
+ reportBackfill(commitOutcome === "committed" ? {
81497
+ status: OutcomeStatuses.succeeded,
81498
+ counts: {
81499
+ events: report.totals.normalizedEvents,
81500
+ crawlerHits: report.totals.crawlerHits,
81501
+ aiReferralHits: report.totals.aiReferralHits,
81502
+ aiUserFetchHits: report.totals.aiUserFetchHits
81503
+ }
81504
+ } : {
81505
+ status: OutcomeStatuses.failed,
81506
+ reasonCode: commitOutcome === "source-inactive" ? OutcomeReasonCodes.NOT_CONNECTED : OutcomeReasonCodes.UNSUPPORTED
81507
+ });
80787
81508
  if (commitOutcome === "source-inactive" || commitOutcome === "wordpress-backfill-unsupported") return;
80788
81509
  } catch (e) {
80789
81510
  markFailed(`Backfill rollup write failed: ${describeError(e)}`);
81511
+ reportBackfill(failedOutcome(e));
80790
81512
  }
80791
81513
  }
81514
+ var TRAFFIC_SOURCE_INTEGRATIONS = {
81515
+ [TrafficSourceTypes.cloudflare]: "traffic_cloudflare",
81516
+ [TrafficSourceTypes["cloud-run"]]: "traffic_cloud_run",
81517
+ [TrafficSourceTypes.vercel]: "traffic_vercel",
81518
+ [TrafficSourceTypes.wordpress]: "traffic_wordpress"
81519
+ };
80792
81520
  async function trafficRoutes(app, opts) {
80793
81521
  const pullEvents = opts.pullCloudRunEvents ?? listCloudRunTrafficEvents;
80794
81522
  const resolveAccessToken2 = opts.resolveCloudRunAccessToken ?? defaultResolveAccessToken;
@@ -80820,9 +81548,10 @@ async function trafficRoutes(app, opts) {
80820
81548
  app.addHook("onClose", async () => {
80821
81549
  if (rawSampleRetentionTimer) clearInterval(rawSampleRetentionTimer);
80822
81550
  });
80823
- async function assertWordpressTargetAllowed(baseUrl) {
81551
+ async function assertWordpressTargetAllowed(baseUrl, attempt) {
80824
81552
  const check2 = await resolveWebhookTarget(baseUrl, { allowLoopback });
80825
81553
  if (!check2.ok) {
81554
+ attempt?.failed(void 0, webhookTargetRefusalReason(check2));
80826
81555
  throw validationError(`WordPress baseUrl rejected: ${check2.message}`);
80827
81556
  }
80828
81557
  }
@@ -80877,7 +81606,7 @@ async function trafficRoutes(app, opts) {
80877
81606
  }
80878
81607
  throw validationError(`Traffic source type "${source.sourceType}" cannot be activated`);
80879
81608
  }
80880
- app.post("/projects/:name/traffic/connect/cloud-run", async (request) => {
81609
+ app.post("/projects/:name/traffic/connect/cloud-run", connectionRoute(app, () => ({ integration: "traffic_cloud_run", action: "connect" }), async (request, _reply, attempt) => {
80881
81610
  const project = resolveProject(app.db, request.params.name);
80882
81611
  const body = request.body ?? {};
80883
81612
  const { gcpProjectId, serviceName, location, displayName, keyJson } = body;
@@ -80903,6 +81632,7 @@ async function trafficRoutes(app, opts) {
80903
81632
  }
80904
81633
  const now = (/* @__PURE__ */ new Date()).toISOString();
80905
81634
  const existing = opts.cloudRunCredentialStore.getConnection(project.name);
81635
+ if (existing) attempt.update({ action: "reauth" });
80906
81636
  opts.cloudRunCredentialStore.upsertConnection({
80907
81637
  projectName: project.name,
80908
81638
  gcpProjectId,
@@ -80966,8 +81696,8 @@ async function trafficRoutes(app, opts) {
80966
81696
  opts.onScheduleUpdated?.("upsert", project.id, SchedulableRunKinds["traffic-sync"]);
80967
81697
  }
80968
81698
  return rowToDto(sourceRow2);
80969
- });
80970
- app.post("/projects/:name/traffic/connect/wordpress", async (request) => {
81699
+ }));
81700
+ app.post("/projects/:name/traffic/connect/wordpress", connectionRoute(app, () => ({ integration: "traffic_wordpress", action: "connect" }), async (request, _reply, attempt) => {
80971
81701
  const project = resolveProject(app.db, request.params.name);
80972
81702
  if (!opts.wordpressTrafficCredentialStore) {
80973
81703
  throw validationError("WordPress traffic credential storage is not configured for this deployment");
@@ -80978,7 +81708,7 @@ async function trafficRoutes(app, opts) {
80978
81708
  throw validationError(parsed.error.issues.map((i) => i.message).join("; "));
80979
81709
  }
80980
81710
  const { baseUrl, username, applicationPassword, displayName } = parsed.data;
80981
- await assertWordpressTargetAllowed(baseUrl);
81711
+ await assertWordpressTargetAllowed(baseUrl, attempt);
80982
81712
  try {
80983
81713
  await pullWordpressEvents({
80984
81714
  baseUrl,
@@ -80989,6 +81719,7 @@ async function trafficRoutes(app, opts) {
80989
81719
  fetchImpl: wordpressTrafficFetch
80990
81720
  });
80991
81721
  } catch (e) {
81722
+ attempt.failed(e, e instanceof EgressRefusedError ? "BLOCKED_UNSAFE_URL" : e instanceof EgressFailedError ? "NETWORK" : void 0);
80992
81723
  if (e instanceof WordpressTrafficApiError) {
80993
81724
  throw providerError(
80994
81725
  `WordPress traffic probe failed (HTTP ${e.status}): ${e.message}${e.body ? ` \u2014 ${e.body}` : ""}`
@@ -81000,6 +81731,7 @@ async function trafficRoutes(app, opts) {
81000
81731
  }
81001
81732
  const now = (/* @__PURE__ */ new Date()).toISOString();
81002
81733
  const existing = credentialStore.getConnection(project.name);
81734
+ if (existing) attempt.update({ action: "reauth" });
81003
81735
  credentialStore.upsertConnection({
81004
81736
  projectName: project.name,
81005
81737
  baseUrl,
@@ -81063,8 +81795,8 @@ async function trafficRoutes(app, opts) {
81063
81795
  opts.onScheduleUpdated?.("upsert", project.id, SchedulableRunKinds["traffic-sync"]);
81064
81796
  }
81065
81797
  return rowToDto(sourceRow2);
81066
- });
81067
- app.post("/projects/:name/traffic/connect/vercel", async (request) => {
81798
+ }));
81799
+ app.post("/projects/:name/traffic/connect/vercel", connectionRoute(app, () => ({ integration: "traffic_vercel", action: "connect" }), async (request, _reply, attempt) => {
81068
81800
  const project = resolveProject(app.db, request.params.name);
81069
81801
  if (!opts.vercelTrafficCredentialStore) {
81070
81802
  throw validationError("Vercel traffic credential storage is not configured for this deployment");
@@ -81088,6 +81820,7 @@ async function trafficRoutes(app, opts) {
81088
81820
  maxPages: 1
81089
81821
  });
81090
81822
  } catch (e) {
81823
+ attempt.failed(e);
81091
81824
  if (e instanceof VercelLogsApiError) {
81092
81825
  throw providerError(
81093
81826
  `Vercel traffic probe failed (HTTP ${e.status}): ${e.message}${e.body ? ` \u2014 ${e.body}` : ""}`
@@ -81098,6 +81831,7 @@ async function trafficRoutes(app, opts) {
81098
81831
  }
81099
81832
  const now = (/* @__PURE__ */ new Date()).toISOString();
81100
81833
  const existing = credentialStore.getConnection(project.name);
81834
+ if (existing) attempt.update({ action: "reauth" });
81101
81835
  credentialStore.upsertConnection({
81102
81836
  projectName: project.name,
81103
81837
  projectId,
@@ -81175,8 +81909,8 @@ async function trafficRoutes(app, opts) {
81175
81909
  opts.onScheduleUpdated?.("upsert", project.id, SchedulableRunKinds["traffic-sync"]);
81176
81910
  }
81177
81911
  return rowToDto(sourceRow2);
81178
- });
81179
- app.post("/projects/:name/traffic/connect/cloudflare", async (request) => {
81912
+ }));
81913
+ app.post("/projects/:name/traffic/connect/cloudflare", connectionRoute(app, () => ({ integration: "traffic_cloudflare", action: "connect" }), async (request, _reply, attempt) => {
81180
81914
  const project = resolveProject(app.db, request.params.name);
81181
81915
  if (!opts.cloudflareTrafficCredentialStore) {
81182
81916
  throw validationError("Cloudflare traffic credential storage is not configured for this deployment");
@@ -81192,6 +81926,7 @@ async function trafficRoutes(app, opts) {
81192
81926
  const now2 = (/* @__PURE__ */ new Date()).toISOString();
81193
81927
  const cloudflareSources = app.db.select().from(trafficSources).where(eq69(trafficSources.projectId, project.id)).all().filter((row) => row.sourceType === TrafficSourceTypes.cloudflare && row.status !== TrafficSourceStatuses.archived);
81194
81928
  const sameModeSource = cloudflareSources.find((row) => parseQueuePullCloudflareSourceConfig(row.configJson));
81929
+ if (sameModeSource) attempt.update({ action: "reauth" });
81195
81930
  const sourceId2 = sameModeSource?.id ?? crypto48.randomUUID();
81196
81931
  const previousCredential2 = credentialStore.getConnectionBySourceId(sourceId2);
81197
81932
  const workerVersion2 = CURRENT_CLOUDFLARE_WORKER_VERSION;
@@ -81327,6 +82062,7 @@ async function trafficRoutes(app, opts) {
81327
82062
  const workerRouteHost = resolveCloudflareWorkerRouteHost(project.canonicalDomain);
81328
82063
  const now = (/* @__PURE__ */ new Date()).toISOString();
81329
82064
  const activeSource = app.db.select().from(trafficSources).where(eq69(trafficSources.projectId, project.id)).all().find((row) => row.sourceType === TrafficSourceTypes.cloudflare && row.status !== TrafficSourceStatuses.archived && parseDirectPushCloudflareSourceConfig(row.configJson) !== null);
82065
+ if (activeSource) attempt.update({ action: "reauth" });
81330
82066
  const sourceId = activeSource?.id ?? crypto48.randomUUID();
81331
82067
  const parsedActiveConfig = activeSource ? parseDirectPushCloudflareSourceConfig(activeSource.configJson) : null;
81332
82068
  if (activeSource && !parsedActiveConfig) {
@@ -81486,8 +82222,12 @@ async function trafficRoutes(app, opts) {
81486
82222
  workerVersion,
81487
82223
  instructions
81488
82224
  };
81489
- });
81490
- app.post("/projects/:name/traffic/sources/:id/activate", async (request) => {
82225
+ }));
82226
+ app.post("/projects/:name/traffic/sources/:id/activate", connectionRoute(app, (request) => {
82227
+ const source = app.db.select({ sourceType: trafficSources.sourceType }).from(trafficSources).where(eq69(trafficSources.id, request.params.id)).get();
82228
+ const integration = source ? TRAFFIC_SOURCE_INTEGRATIONS[source.sourceType] : void 0;
82229
+ return integration ? { integration, action: "select" } : void 0;
82230
+ }, async (request) => {
81491
82231
  const project = resolveProject(app.db, request.params.name);
81492
82232
  const now = (/* @__PURE__ */ new Date()).toISOString();
81493
82233
  const result = app.db.transaction((tx) => {
@@ -81538,7 +82278,7 @@ async function trafficRoutes(app, opts) {
81538
82278
  opts.onScheduleUpdated?.(result.scheduleAction, project.id, SchedulableRunKinds["traffic-sync"]);
81539
82279
  }
81540
82280
  return rowToDto(result.source);
81541
- });
82281
+ }));
81542
82282
  app.post("/projects/:name/traffic/cloudflare/ingest", {
81543
82283
  bodyLimit: CLOUDFLARE_INGEST_BODY_LIMIT,
81544
82284
  preHandler: async (request, reply) => {
@@ -81567,6 +82307,22 @@ async function trafficRoutes(app, opts) {
81567
82307
  }
81568
82308
  }
81569
82309
  }, async (request, reply) => {
82310
+ const ingestStartedMs = Date.now();
82311
+ const reportIngest = (outcome2) => {
82312
+ try {
82313
+ opts.onTrafficIngested?.("failure" in outcome2 ? {
82314
+ sourceType: TrafficSourceTypes.cloudflare,
82315
+ status: "failed",
82316
+ events: 0,
82317
+ crawlerHits: 0,
82318
+ aiReferralHits: 0,
82319
+ aiUserFetchHits: 0,
82320
+ durationMs: Date.now() - ingestStartedMs,
82321
+ ...routeOutcomeFailure(outcome2.failure, outcome2.reasonCode)
82322
+ } : { sourceType: TrafficSourceTypes.cloudflare, status: "succeeded", ...outcome2, durationMs: Date.now() - ingestStartedMs });
82323
+ } catch {
82324
+ }
82325
+ };
81570
82326
  const authenticated = authenticateCloudflareIngest(
81571
82327
  request,
81572
82328
  opts.cloudflareTrafficCredentialStore
@@ -81584,6 +82340,7 @@ async function trafficRoutes(app, opts) {
81584
82340
  if (sourceRow2.projectId !== project.id) throw authRequired();
81585
82341
  const parsed = cloudflareWorkerIngestRequestSchema.safeParse(request.body);
81586
82342
  if (!parsed.success) {
82343
+ reportIngest({ failure: void 0, reasonCode: "VALIDATION" });
81587
82344
  throw validationError(parsed.error.issues.map((i) => i.message).join("; "));
81588
82345
  }
81589
82346
  const { workerVersion, events } = parsed.data;
@@ -81592,6 +82349,7 @@ async function trafficRoutes(app, opts) {
81592
82349
  (event) => normalizeCloudflareEventHost(event.host) !== canonicalHost
81593
82350
  );
81594
82351
  if (wrongHostIndex !== -1) {
82352
+ reportIngest({ failure: void 0, reasonCode: "VALIDATION" });
81595
82353
  throw validationError(
81596
82354
  `Cloudflare event at index ${wrongHostIndex} does not belong to project domain "${project.canonicalDomain}"`
81597
82355
  );
@@ -81604,25 +82362,37 @@ async function trafficRoutes(app, opts) {
81604
82362
  else normalizationDrops += 1;
81605
82363
  }
81606
82364
  const receivedAt = (/* @__PURE__ */ new Date()).toISOString();
81607
- const writeResult = writeTrafficEventBatch({
81608
- db: app.db,
81609
- projectId: project.id,
81610
- sourceId,
81611
- events: normalized,
81612
- receivedAt,
81613
- receiptTtlMs: DIRECT_PUSH_RECEIPT_TTL_MS,
81614
- sampleLimit,
81615
- validateSource: (latestRow) => {
81616
- if (!latestRow || latestRow.projectId !== project.id || latestRow.sourceType !== TrafficSourceTypes.cloudflare || latestRow.status !== TrafficSourceStatuses.connected || !parseDirectPushCloudflareSourceConfig(latestRow.configJson) || !timingSafeEqualHex(bearerHash, latestRow.ingestTokenHash)) {
81617
- throw authRequired();
82365
+ let writeResult;
82366
+ try {
82367
+ writeResult = writeTrafficEventBatch({
82368
+ db: app.db,
82369
+ projectId: project.id,
82370
+ sourceId,
82371
+ events: normalized,
82372
+ receivedAt,
82373
+ receiptTtlMs: DIRECT_PUSH_RECEIPT_TTL_MS,
82374
+ sampleLimit,
82375
+ validateSource: (latestRow) => {
82376
+ if (!latestRow || latestRow.projectId !== project.id || latestRow.sourceType !== TrafficSourceTypes.cloudflare || latestRow.status !== TrafficSourceStatuses.connected || !parseDirectPushCloudflareSourceConfig(latestRow.configJson) || !timingSafeEqualHex(bearerHash, latestRow.ingestTokenHash)) {
82377
+ throw authRequired();
82378
+ }
82379
+ },
82380
+ sourceUpdate: {
82381
+ lastWorkerVersion: workerVersion,
82382
+ lastSyncedAt: receivedAt,
82383
+ lastError: null,
82384
+ updatedAt: receivedAt
81618
82385
  }
81619
- },
81620
- sourceUpdate: {
81621
- lastWorkerVersion: workerVersion,
81622
- lastSyncedAt: receivedAt,
81623
- lastError: null,
81624
- updatedAt: receivedAt
81625
- }
82386
+ });
82387
+ } catch (err) {
82388
+ reportIngest({ failure: err });
82389
+ throw err;
82390
+ }
82391
+ reportIngest({
82392
+ events: writeResult.acceptedEvents,
82393
+ crawlerHits: writeResult.crawlerHits,
82394
+ aiReferralHits: writeResult.aiReferralHits,
82395
+ aiUserFetchHits: writeResult.aiUserFetchHits
81626
82396
  });
81627
82397
  return reply.status(200).send({
81628
82398
  acceptedEvents: writeResult.acceptedEvents,
@@ -81860,6 +82630,22 @@ async function trafficRoutes(app, opts) {
81860
82630
  } catch (error) {
81861
82631
  const failedAt = (/* @__PURE__ */ new Date()).toISOString();
81862
82632
  const safeError = error instanceof CloudflareQueueApiError ? error.message : "Cloudflare Queue sync failed; retry the active source.";
82633
+ try {
82634
+ opts.onTrafficSynced?.({
82635
+ status: "failed",
82636
+ sourceType: sourceRow2.sourceType,
82637
+ sourceId: sourceRow2.id,
82638
+ pulledEvents: 0,
82639
+ selfTrafficExcluded: 0,
82640
+ crawlerHits: 0,
82641
+ aiUserFetchHits: 0,
82642
+ aiReferralHits: 0,
82643
+ durationMs: Date.now() - startedMs,
82644
+ // A 401/403 is the stored API token; any other Queue API answer is the pull.
82645
+ errorCode: error instanceof CloudflareQueueApiError ? error.status === 401 || error.status === 403 ? "PROVIDER_AUTH" : "PROVIDER_PULL" : "INTERNAL"
82646
+ });
82647
+ } catch {
82648
+ }
81863
82649
  app.db.transaction((tx) => {
81864
82650
  tx.update(runs).set({ status: RunStatuses.failed, error: safeError, finishedAt: failedAt }).where(eq69(runs.id, runId)).run();
81865
82651
  const latestSource = tx.select().from(trafficSources).where(eq69(trafficSources.id, sourceRow2.id)).get();
@@ -82573,7 +83359,7 @@ async function trafficRoutes(app, opts) {
82573
83359
  }
82574
83360
  }
82575
83361
  });
82576
- app.post("/projects/:name/traffic/sources/:id/backfill", async (request) => {
83362
+ app.post("/projects/:name/traffic/sources/:id/backfill", async (request) => withFeatureOutcome(app, { feature: "server_traffic", operation: "backfill" }, async (recordOutcome) => {
82577
83363
  const project = resolveProject(app.db, request.params.name);
82578
83364
  const sourceRow2 = app.db.select().from(trafficSources).where(eq69(trafficSources.id, request.params.id)).get();
82579
83365
  if (!sourceRow2 || sourceRow2.projectId !== project.id) {
@@ -82714,6 +83500,7 @@ async function trafficRoutes(app, opts) {
82714
83500
  pullErrorPrefix
82715
83501
  }).catch(() => {
82716
83502
  });
83503
+ recordOutcome(null);
82717
83504
  const response = {
82718
83505
  sourceId: sourceRow2.id,
82719
83506
  runId,
@@ -82724,7 +83511,7 @@ async function trafficRoutes(app, opts) {
82724
83511
  daysApplied: appliedDays
82725
83512
  };
82726
83513
  return response;
82727
- });
83514
+ }));
82728
83515
  function buildSourceDetail(projectId, row, since) {
82729
83516
  const crawlerPathRows = app.db.select({
82730
83517
  pathNormalized: crawlerEventsHourly.pathNormalized,
@@ -82790,7 +83577,7 @@ async function trafficRoutes(app, opts) {
82790
83577
  } : null
82791
83578
  };
82792
83579
  }
82793
- app.post("/projects/:name/traffic/sources/:id/reset", async (request) => {
83580
+ app.post("/projects/:name/traffic/sources/:id/reset", async (request) => withFeatureOutcome(app, { feature: "server_traffic", operation: "reset" }, async () => {
82794
83581
  const project = resolveProject(app.db, request.params.name);
82795
83582
  const parsed = trafficResetRequestSchema.safeParse(request.body ?? {});
82796
83583
  if (!parsed.success) {
@@ -82843,7 +83630,7 @@ async function trafficRoutes(app, opts) {
82843
83630
  updatedRow = tx.select().from(trafficSources).where(eq69(trafficSources.id, sourceRow2.id)).get();
82844
83631
  });
82845
83632
  return buildSourceDetail(project.id, updatedRow, new Date(Date.now() - 24 * 60 * 6e4).toISOString());
82846
- });
83633
+ }));
82847
83634
  app.get("/projects/:name/traffic/sources", async (request) => {
82848
83635
  const project = resolveProject(app.db, request.params.name);
82849
83636
  const rows = app.db.select().from(trafficSources).where(eq69(trafficSources.projectId, project.id)).orderBy(desc35(trafficSources.createdAt)).all();
@@ -90894,6 +91681,7 @@ function captureSimpleMeasurementDefinition(db, input) {
90894
91681
  // ../api-routes/src/index.ts
90895
91682
  async function apiRoutes(app, opts) {
90896
91683
  app.decorate("db", opts.db);
91684
+ app.decorate("emitOutcome", createOutcomeEmitter(opts.onOutcome));
90897
91685
  app.setErrorHandler((error, _request, reply) => {
90898
91686
  if (error instanceof AppError) {
90899
91687
  return reply.status(error.statusCode).send(error.toJSON());
@@ -91167,6 +91955,7 @@ async function apiRoutes(app, opts) {
91167
91955
  cloudflareIngestIpRateLimitMax: opts.cloudflareIngestIpRateLimitMax,
91168
91956
  defaultSampleLimit: opts.defaultTrafficSampleLimit,
91169
91957
  onTrafficSynced: opts.onTrafficSynced,
91958
+ onTrafficIngested: opts.onTrafficIngested,
91170
91959
  onScheduleUpdated: opts.onScheduleUpdated,
91171
91960
  allowLoopbackWebhooks: opts.allowLoopbackWebhooks
91172
91961
  });
@@ -92497,6 +93286,7 @@ export {
92497
93286
  bingConnections,
92498
93287
  bingUrlInspections,
92499
93288
  bingKeywordStats,
93289
+ gaConnections,
92500
93290
  gaTrafficSnapshots,
92501
93291
  gaDailyTotals,
92502
93292
  gaAcquisitionDaily,
@@ -92574,6 +93364,7 @@ export {
92574
93364
  createCredentialChecker,
92575
93365
  requireInstanceAdministrator,
92576
93366
  hashApiKey,
93367
+ currentOutcomeAttribution,
92577
93368
  notProbeRun,
92578
93369
  writeAuditLog,
92579
93370
  auditFromRequest,
@@ -92587,9 +93378,15 @@ export {
92587
93378
  SentimentService,
92588
93379
  registerOAuthRoutes,
92589
93380
  registerOAuthAdminRoutes,
93381
+ credentialFailure,
92590
93382
  createRunCompetitorResolver,
92591
93383
  competitorIdentityColumns,
92592
93384
  readMarketCompetitorNames,
93385
+ toAlertView,
93386
+ resolveDestination,
93387
+ webhookOutcomeTarget,
93388
+ webhookTargetRefusalReason,
93389
+ webhookResponseReason,
92593
93390
  latestQueryBasketRevision,
92594
93391
  ensureCurrentQueryBasketRevision,
92595
93392
  queueRunIfProjectIdle,
@@ -92609,19 +93406,20 @@ export {
92609
93406
  deliverWebhook,
92610
93407
  redactNotificationUrl,
92611
93408
  buildOpenApiDocument,
92612
- toAlertView,
92613
- resolveDestination,
92614
93409
  getPlaceDetails,
92615
93410
  hashPlaceDetails,
92616
93411
  getPlaceReviewSignals,
92617
93412
  GSC_REPORTING_TIME_ZONE,
92618
93413
  GSC_DATA_LAG_DAYS,
93414
+ GoogleAuthError,
93415
+ GoogleApiError,
92619
93416
  getAuthUrl,
92620
93417
  exchangeCode,
92621
93418
  refreshAccessToken,
92622
93419
  fetchSearchAnalytics,
92623
93420
  inspectUrl,
92624
93421
  GBP_DAILY_METRICS,
93422
+ GbpApiError,
92625
93423
  listLocations,
92626
93424
  formatStorefrontAddress,
92627
93425
  buildLocationProfileFields,
@@ -92636,6 +93434,7 @@ export {
92636
93434
  hashAttributes,
92637
93435
  listReviews,
92638
93436
  classifyReviewsError,
93437
+ BingApiError,
92639
93438
  getUrlInfo,
92640
93439
  getCrawlIssues,
92641
93440
  EgressRefusedError,