@canonry/canonry 7.23.0 → 7.24.1

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 (30) hide show
  1. package/assets/assets/{AuditHistoryPanel-J8AR3x5F.js → AuditHistoryPanel-DK3zE9dO.js} +1 -1
  2. package/assets/assets/{BacklinksPage-CRDPutGA.js → BacklinksPage-WYFDcXpX.js} +1 -1
  3. package/assets/assets/{HistoryPage-C5taVW3t.js → HistoryPage-CSlndAeS.js} +1 -1
  4. package/assets/assets/{MeasurementPropertyPage-txucmLuz.js → MeasurementPropertyPage-CivoZOGQ.js} +1 -1
  5. package/assets/assets/{ProjectPage-C4FaBoWI.js → ProjectPage-zidnYv65.js} +1 -1
  6. package/assets/assets/{RunsPage-D-3XKUhh.js → RunsPage-BGMAZ_mA.js} +1 -1
  7. package/assets/assets/{SettingsPage-Cq_SJRj_.js → SettingsPage-ndSVVJcE.js} +1 -1
  8. package/assets/assets/{SiteHealthSection-Cn_IZVO1.js → SiteHealthSection-C7-RrsKk.js} +3 -3
  9. package/assets/assets/{TrafficPage-BMdFZs3X.js → TrafficPage-2ANBE9Lc.js} +1 -1
  10. package/assets/assets/{TrafficSourceDetailPage-BmrIOc-2.js → TrafficSourceDetailPage-CLYVB4Uq.js} +1 -1
  11. package/assets/assets/{extract-error-message-HlQy5L7O.js → extract-error-message-BzPoRYiA.js} +1 -1
  12. package/assets/assets/index-CjowCLs3.js +86 -0
  13. package/assets/assets/{react-sigma_core.esm.min-CogAr1i4.js → react-sigma_core.esm.min-BmKqjMGN.js} +1 -1
  14. package/assets/assets/{v2-overview-adapter-tps7TUxq.js → v2-overview-adapter-C7eI2iBf.js} +1 -1
  15. package/assets/index.html +1 -1
  16. package/dist/{chunk-5IXQAOVK.js → chunk-332IBSJJ.js} +15 -6
  17. package/dist/{chunk-3OPV5XBY.js → chunk-35IRQYDW.js} +5709 -4588
  18. package/dist/{chunk-222NAFTY.js → chunk-43FZZFOK.js} +2 -2
  19. package/dist/{chunk-UF7MH6TU.js → chunk-5ITYNLS3.js} +2 -2
  20. package/dist/{chunk-MRKV43TV.js → chunk-656KW4RB.js} +566 -206
  21. package/dist/{chunk-46JZR2IV.js → chunk-H7UNFLYH.js} +1306 -505
  22. package/dist/{chunk-KWBFKTOA.js → chunk-OAWFZAAJ.js} +3 -3
  23. package/dist/cli.js +199 -49
  24. package/dist/{demo-server-32PU7BM6.js → demo-server-NLD6B4WH.js} +4 -4
  25. package/dist/index.d.ts +2 -0
  26. package/dist/index.js +6 -6
  27. package/dist/{intelligence-service-256DQBVR.js → intelligence-service-JFHM2EAY.js} +2 -2
  28. package/dist/mcp.js +4 -4
  29. package/package.json +11 -11
  30. 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-656KW4RB.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")) {
@@ -46846,7 +47459,9 @@ async function telemetryRoutes(app, opts) {
46846
47459
  opts.recordOnboardingEvent?.(normalizeOnboardingEventForCollection(parsed.data));
46847
47460
  return reply.status(202).send({ accepted: Boolean(opts.recordOnboardingEvent) });
46848
47461
  });
46849
- app.post("/telemetry/ui", async (request, reply) => {
47462
+ app.post("/telemetry/ui", {
47463
+ config: { readSemantic: true }
47464
+ }, async (request, reply) => {
46850
47465
  const parsed = uiTelemetryEventSchema.safeParse(request.body);
46851
47466
  if (!parsed.success) {
46852
47467
  throw validationError("Invalid UI telemetry event", {
@@ -47132,311 +47747,32 @@ function formatSchedule(row) {
47132
47747
  // ../api-routes/src/notifications.ts
47133
47748
  import crypto30 from "crypto";
47134
47749
  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.");
47750
+ var AGENT_WEBHOOK_SOURCE = "agent";
47751
+ function webhookConnection(action, url, source) {
47190
47752
  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) {
47348
- 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
- }]
47753
+ integration: source === AGENT_WEBHOOK_SOURCE ? "agent_webhook" : "webhook",
47754
+ action,
47755
+ ...typeof url === "string" && url ? { target: webhookOutcomeTarget(url) } : {}
47392
47756
  };
47393
47757
  }
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
47758
  var VALID_EVENTS = notificationEventSchema.options;
47429
47759
  async function notificationRoutes(app, opts = {}) {
47430
47760
  const allowLoopback = opts.allowLoopbackWebhooks === true;
47431
47761
  app.get("/notifications/events", async (_request, reply) => {
47432
47762
  return reply.send(VALID_EVENTS);
47433
47763
  });
47434
- app.post("/projects/:name/notifications", async (request, reply) => {
47764
+ app.post("/projects/:name/notifications", connectionRoute(app, (request) => {
47765
+ const body = request.body;
47766
+ return webhookConnection("connect", body?.url, body?.source);
47767
+ }, async (request, reply, attempt) => {
47435
47768
  const project = resolveProject(app.db, request.params.name);
47436
47769
  const { channel, url, events, source } = request.body ?? {};
47437
47770
  if (channel !== "webhook") throw validationError('Only "webhook" channel is supported');
47438
47771
  const urlCheck = await resolveWebhookTarget(url ?? "", { allowLoopback });
47439
- if (!urlCheck.ok) throw validationError(urlCheck.message);
47772
+ if (!urlCheck.ok) {
47773
+ attempt.failed(void 0, webhookTargetRefusalReason(urlCheck));
47774
+ throw validationError(urlCheck.message);
47775
+ }
47440
47776
  if (!events?.length) throw validationError('"events" must be a non-empty array');
47441
47777
  const invalid = events.filter((e) => !VALID_EVENTS.includes(e));
47442
47778
  if (invalid.length) {
@@ -47467,13 +47803,17 @@ async function notificationRoutes(app, opts = {}) {
47467
47803
  ...formatNotification(app.db.select().from(notifications).where(eq56(notifications.id, id)).get()),
47468
47804
  webhookSecret
47469
47805
  });
47470
- });
47806
+ }));
47471
47807
  app.get("/projects/:name/notifications", async (request, reply) => {
47472
47808
  const project = resolveProject(app.db, request.params.name);
47473
47809
  const rows = app.db.select().from(notifications).where(eq56(notifications.projectId, project.id)).all();
47474
47810
  return reply.send(rows.map(formatNotification));
47475
47811
  });
47476
- app.delete("/projects/:name/notifications/:id", async (request, reply) => {
47812
+ app.delete("/projects/:name/notifications/:id", connectionRoute(app, (request) => {
47813
+ const stored = app.db.select({ config: notifications.config }).from(notifications).where(eq56(notifications.id, request.params.id)).get();
47814
+ const config = stored?.config;
47815
+ return config ? webhookConnection("disconnect", config.url, config.source) : void 0;
47816
+ }, async (request, reply) => {
47477
47817
  const project = resolveProject(app.db, request.params.name);
47478
47818
  const notification = app.db.select().from(notifications).where(eq56(notifications.id, request.params.id)).get();
47479
47819
  if (!notification || notification.projectId !== project.id) {
@@ -47488,8 +47828,12 @@ async function notificationRoutes(app, opts = {}) {
47488
47828
  entityId: notification.id
47489
47829
  });
47490
47830
  return reply.status(204).send();
47491
- });
47492
- app.post("/projects/:name/notifications/:id/test", async (request, reply) => {
47831
+ }));
47832
+ app.post("/projects/:name/notifications/:id/test", connectionRoute(app, (request) => {
47833
+ const stored = app.db.select({ config: notifications.config }).from(notifications).where(eq56(notifications.id, request.params.id)).get();
47834
+ const config = stored?.config;
47835
+ return config ? webhookConnection("test", config.url, config.source) : void 0;
47836
+ }, async (request, reply, attempt) => {
47493
47837
  const project = resolveProject(app.db, request.params.name);
47494
47838
  const notification = app.db.select().from(notifications).where(eq56(notifications.id, request.params.id)).get();
47495
47839
  if (!notification || notification.projectId !== project.id) {
@@ -47497,7 +47841,10 @@ async function notificationRoutes(app, opts = {}) {
47497
47841
  }
47498
47842
  const config = notification.config;
47499
47843
  const urlCheck = await resolveWebhookTarget(config.url, { allowLoopback });
47500
- if (!urlCheck.ok) throw validationError(`Stored webhook URL is invalid: ${urlCheck.message}`);
47844
+ if (!urlCheck.ok) {
47845
+ attempt.failed(void 0, webhookTargetRefusalReason(urlCheck));
47846
+ throw validationError(`Stored webhook URL is invalid: ${urlCheck.message}`);
47847
+ }
47501
47848
  const payload = {
47502
47849
  source: "canonry",
47503
47850
  event: "run.completed",
@@ -47516,8 +47863,11 @@ async function notificationRoutes(app, opts = {}) {
47516
47863
  const signingSecret = destination.signed ? notification.webhookSecret ?? null : null;
47517
47864
  const targetLabel = redactNotificationUrl(config.url).urlDisplay;
47518
47865
  request.log.info(`[Notification test] POST ${targetLabel} (${destination.destination})`);
47519
- const { status, error } = await deliverWebhook(urlCheck.target, body, signingSecret);
47866
+ const delivery = await deliverWebhook(urlCheck.target, body, signingSecret);
47867
+ const { status, error } = delivery;
47520
47868
  request.log.info(`[Notification test] Response: HTTP ${status} from ${targetLabel}`);
47869
+ const failure = webhookResponseReason(delivery);
47870
+ if (failure) attempt.failed(void 0, failure);
47521
47871
  writeAuditLog(app.db, {
47522
47872
  projectId: project.id,
47523
47873
  actor: "api",
@@ -47528,7 +47878,7 @@ async function notificationRoutes(app, opts = {}) {
47528
47878
  });
47529
47879
  if (error) throw deliveryFailed(error);
47530
47880
  return reply.send({ status, ok: status >= 200 && status < 300 });
47531
- });
47881
+ }));
47532
47882
  }
47533
47883
  function formatNotification(row) {
47534
47884
  const config = row.config;
@@ -49769,6 +50119,10 @@ function classifyReviewsError(err) {
49769
50119
  }
49770
50120
 
49771
50121
  // ../api-routes/src/google.ts
50122
+ var GOOGLE_INTEGRATIONS = { gsc: "gsc", ga4: "ga4", gbp: "gbp" };
50123
+ function googleIntegration(type) {
50124
+ return typeof type === "string" && Object.hasOwn(GOOGLE_INTEGRATIONS, type) ? GOOGLE_INTEGRATIONS[type] : void 0;
50125
+ }
49772
50126
  function resolveReportedWindow(resolved, startDate, endDate) {
49773
50127
  const start = startDate ?? resolved.startDate;
49774
50128
  const end = endDate ?? resolved.endDate;
@@ -49902,6 +50256,19 @@ function googleAuthErrorStatus(err) {
49902
50256
  const match = err.message.match(/failed \((\d{3})\)/);
49903
50257
  return match ? Number(match[1]) : null;
49904
50258
  }
50259
+ function gscOutcomeFailure(err) {
50260
+ const classified = classifyOutcomeError(err);
50261
+ if (err instanceof AppError && (err.code === "NOT_FOUND" || err.code === "VALIDATION_ERROR")) {
50262
+ return { ...classified, reasonCode: OutcomeReasonCodes.NOT_CONNECTED };
50263
+ }
50264
+ if (err instanceof GoogleAuthError) {
50265
+ const status = googleAuthErrorStatus(err);
50266
+ const reasonCode = status === 429 ? OutcomeReasonCodes.RATE_LIMITED : status !== null && status >= 500 ? OutcomeReasonCodes.HTTP_5XX : OutcomeReasonCodes.INVALID_CREDENTIALS;
50267
+ return { ...classified, reasonCode };
50268
+ }
50269
+ if (err instanceof GoogleApiError && err.status === 404) return { ...classified, reasonCode: OutcomeReasonCodes.PROPERTY_NOT_FOUND };
50270
+ return classified;
50271
+ }
49905
50272
  function gscErrorToAppError(err, context) {
49906
50273
  if (err instanceof AppError) return err;
49907
50274
  if (err instanceof GoogleApiError) {
@@ -49985,7 +50352,10 @@ async function googleRoutes(app, opts) {
49985
50352
  updatedAt: connection.updatedAt
49986
50353
  }));
49987
50354
  });
49988
- app.post("/projects/:name/google/connect", async (request) => {
50355
+ app.post("/projects/:name/google/connect", connectionRoute(app, (request) => {
50356
+ const integration = googleIntegration(request.body?.type);
50357
+ return integration ? { integration, action: "connect" } : void 0;
50358
+ }, async (request, _reply, attempt) => {
49989
50359
  const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
49990
50360
  if (!googleClientId || !googleClientSecret) {
49991
50361
  throw validationError("Google OAuth is not configured. Set Google OAuth credentials in the local Canonry config.");
@@ -49995,6 +50365,7 @@ async function googleRoutes(app, opts) {
49995
50365
  throw validationError('type must be "gsc", "ga4", or "gbp"');
49996
50366
  }
49997
50367
  const project = resolveProject(app.db, request.params.name);
50368
+ if (telemetryRead(() => opts.googleConnectionStore?.getConnection(project.canonicalDomain, type), void 0)) attempt.update({ action: "reauth" });
49998
50369
  let redirectUri;
49999
50370
  if (publicUrl) {
50000
50371
  redirectUri = publicUrl.replace(/\/$/, "") + "/api/v1/google/callback";
@@ -50013,22 +50384,40 @@ async function googleRoutes(app, opts) {
50013
50384
  domain: project.canonicalDomain,
50014
50385
  type,
50015
50386
  propertyId,
50016
- redirectUri
50387
+ redirectUri,
50388
+ // The browser finishes the flow, so the callback reports the surface that started it.
50389
+ ...oauthStartAttribution()
50017
50390
  },
50018
50391
  stateSecret
50019
50392
  );
50020
50393
  const authUrl = getAuthUrl(googleClientId, redirectUri, scopes, stateEncoded);
50394
+ attempt.started();
50021
50395
  return { authUrl, redirectUri };
50022
- });
50396
+ }));
50023
50397
  async function handleOAuthCallback(request, reply) {
50398
+ const verifiedState = request.query.state ? verifySignedGoogleOAuthState(request.query.state, stateSecret) : null;
50399
+ const integration = googleIntegration(verifiedState?.type ?? unverifiedOAuthStateField(request.query.state, "type"));
50400
+ const replacesConnection = integration && typeof verifiedState?.domain === "string" && telemetryRead(() => opts.googleConnectionStore?.getConnection(verifiedState.domain, integration), void 0);
50401
+ const attempt = startConnectionAttempt(app, integration ? { integration, action: replacesConnection ? "reauth" : "connect", ...oauthStateAttribution(verifiedState) } : void 0);
50402
+ try {
50403
+ return await completeOAuthCallback(request, reply, attempt);
50404
+ } catch (err) {
50405
+ attempt.failed(err);
50406
+ throw err;
50407
+ }
50408
+ }
50409
+ async function completeOAuthCallback(request, reply, attempt) {
50024
50410
  const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
50025
50411
  if (!googleClientId || !googleClientSecret) {
50412
+ attempt.failed(void 0, "UNSUPPORTED");
50026
50413
  return reply.status(500).send("Google OAuth not configured");
50027
50414
  }
50028
50415
  const store = requireConnectionStore();
50029
50416
  const escapeHtml3 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
50030
50417
  const { code, state, error } = request.query;
50031
50418
  if (error) {
50419
+ if (error === "access_denied") attempt.cancelled("OAUTH_CANCELLED");
50420
+ else attempt.failed(void 0, "AUTH_DENIED");
50032
50421
  const safeError = escapeHtml3(String(error));
50033
50422
  const errorHtml = error === "redirect_uri_mismatch" ? `<html><body style="font-family:system-ui;padding:40px;max-width:600px;margin:0 auto">
50034
50423
  <h2 style="color:#ef4444">Redirect URI mismatch</h2>
@@ -50055,21 +50444,26 @@ async function googleRoutes(app, opts) {
50055
50444
  return reply.type("text/html").send(errorHtml);
50056
50445
  }
50057
50446
  if (!code || !state) {
50447
+ attempt.failed(void 0, state ? "VALIDATION" : "OAUTH_STATE_INVALID");
50058
50448
  return reply.status(400).send("Missing code or state parameter");
50059
50449
  }
50060
50450
  const stateData = verifySignedGoogleOAuthState(state, stateSecret);
50061
50451
  if (!stateData) {
50452
+ attempt.failed(void 0, "OAUTH_STATE_INVALID");
50062
50453
  return reply.status(400).send("Invalid or tampered state parameter");
50063
50454
  }
50064
50455
  const { domain, type, propertyId, redirectUri, projectId, projectName } = stateData;
50065
50456
  if (!projectId) {
50457
+ attempt.failed(void 0, "OAUTH_STATE_INVALID");
50066
50458
  return reply.status(400).send("Stale OAuth state \u2014 restart the connect flow.");
50067
50459
  }
50068
50460
  const project = app.db.select().from(projects).where(eq57(projects.id, projectId)).get();
50069
50461
  if (!project) {
50462
+ attempt.failed(void 0, "NOT_FOUND");
50070
50463
  return reply.status(400).send("Project no longer exists. Restart the connect flow.");
50071
50464
  }
50072
50465
  if (project.canonicalDomain.toLowerCase() !== domain.toLowerCase()) {
50466
+ attempt.failed(void 0, "OAUTH_STATE_INVALID");
50073
50467
  return reply.status(400).send(
50074
50468
  `Project "${projectName ?? project.name}" canonical domain changed since this OAuth flow started. Expected "${domain}", got "${project.canonicalDomain}". Restart the connect flow.`
50075
50469
  );
@@ -50078,6 +50472,7 @@ async function googleRoutes(app, opts) {
50078
50472
  try {
50079
50473
  tokens = await exchangeCode(googleClientId, googleClientSecret, code, redirectUri);
50080
50474
  } catch (err) {
50475
+ attempt.failed(err);
50081
50476
  const msg = describeError(err);
50082
50477
  return reply.type("text/html").send(
50083
50478
  `<html><body style="font-family:system-ui;padding:40px;max-width:600px;margin:0 auto">
@@ -50095,6 +50490,7 @@ async function googleRoutes(app, opts) {
50095
50490
  const expiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
50096
50491
  const existing = store.getConnection(domain, type);
50097
50492
  if (existing && existing.createdByProjectId && existing.createdByProjectId !== projectId) {
50493
+ attempt.failed(void 0, "ALREADY_CONNECTED");
50098
50494
  return reply.status(403).send(
50099
50495
  `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
50496
  );
@@ -50121,6 +50517,7 @@ async function googleRoutes(app, opts) {
50121
50517
  entityId: type,
50122
50518
  diff: { domain, type, propertyId }
50123
50519
  });
50520
+ attempt.succeeded();
50124
50521
  return reply.type("text/html").send(googleOAuthSuccessHtml(type));
50125
50522
  }
50126
50523
  app.get("/google/callback", async (request, reply) => {
@@ -50129,12 +50526,16 @@ async function googleRoutes(app, opts) {
50129
50526
  app.get("/projects/:name/google/callback", async (request, reply) => {
50130
50527
  return handleOAuthCallback(request, reply);
50131
50528
  });
50132
- app.delete("/projects/:name/google/connections/:type", async (request, reply) => {
50529
+ app.delete("/projects/:name/google/connections/:type", connectionRoute(app, (request) => {
50530
+ const integration = googleIntegration(request.params.type);
50531
+ return integration ? { integration, action: "disconnect" } : void 0;
50532
+ }, async (request, reply, attempt) => {
50133
50533
  const store = requireConnectionStore();
50134
50534
  const project = resolveProject(app.db, request.params.name);
50135
50535
  const type = request.params.type;
50136
50536
  const existing = store.getConnection(project.canonicalDomain, type);
50137
50537
  if (!existing) {
50538
+ attempt.failed(void 0, "NOT_CONNECTED");
50138
50539
  throw notFound("Google connection", type);
50139
50540
  }
50140
50541
  if (existing.createdByProjectId && existing.createdByProjectId !== project.id) {
@@ -50144,6 +50545,7 @@ async function googleRoutes(app, opts) {
50144
50545
  }
50145
50546
  const deleted = store.deleteConnection(project.canonicalDomain, type);
50146
50547
  if (!deleted) {
50548
+ attempt.failed(void 0, "NOT_CONNECTED");
50147
50549
  throw notFound("Google connection", type);
50148
50550
  }
50149
50551
  writeAuditLog(app.db, {
@@ -50154,7 +50556,7 @@ async function googleRoutes(app, opts) {
50154
50556
  entityId: type
50155
50557
  });
50156
50558
  return reply.status(204).send();
50157
- });
50559
+ }));
50158
50560
  app.get("/projects/:name/google/properties", async (request) => {
50159
50561
  assertNotProjectScoped(request, "listing Search Console properties");
50160
50562
  const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
@@ -50472,14 +50874,16 @@ async function googleRoutes(app, opts) {
50472
50874
  if (!url) {
50473
50875
  throw validationError("url is required");
50474
50876
  }
50877
+ const outcome2 = startRouteOutcome(app, FeatureNames.search_console, "inspect");
50475
50878
  let result;
50476
50879
  try {
50477
50880
  const { accessToken, propertyId } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
50478
50881
  if (!propertyId) {
50479
- throw validationError("No GSC property configured for this connection");
50882
+ throw outcome2.refuse(OutcomeReasonCodes.PROPERTY_NOT_FOUND, validationError("No GSC property configured for this connection"));
50480
50883
  }
50481
50884
  result = await inspectUrl(accessToken, url, propertyId);
50482
50885
  } catch (err) {
50886
+ outcome2.report({ status: OutcomeStatuses.failed, ...gscOutcomeFailure(err) });
50483
50887
  throw gscErrorToAppError(err, "Failed to inspect URL in Search Console");
50484
50888
  }
50485
50889
  const ir = result.inspectionResult;
@@ -50506,6 +50910,7 @@ async function googleRoutes(app, opts) {
50506
50910
  inspectedAt: now,
50507
50911
  createdAt: now
50508
50912
  }).run();
50913
+ outcome2.report({ status: OutcomeStatuses.succeeded, counts: { urls: 1 } });
50509
50914
  return {
50510
50915
  id,
50511
50916
  url,
@@ -50730,31 +51135,43 @@ async function googleRoutes(app, opts) {
50730
51135
  const sitemapUrls = [...new Set(parsed.data.sitemapUrls)];
50731
51136
  const project = resolveProject(app.db, request.params.name);
50732
51137
  const store = requireConnectionStore();
51138
+ const outcome2 = startRouteOutcome(app, FeatureNames.search_console, "sitemap_submit");
50733
51139
  const conn = store.getConnection(project.canonicalDomain, "gsc");
50734
51140
  if (!conn) {
50735
- throw validationError('No GSC connection found for this domain. Run "canonry google connect" first.');
51141
+ throw outcome2.refuse(OutcomeReasonCodes.NOT_CONNECTED, validationError('No GSC connection found for this domain. Run "canonry google connect" first.'));
50736
51142
  }
50737
51143
  if (!conn.propertyId) {
50738
- throw validationError('No GSC property configured for this connection. Set one with "canonry google set-property".');
51144
+ throw outcome2.refuse(
51145
+ OutcomeReasonCodes.PROPERTY_NOT_FOUND,
51146
+ validationError('No GSC property configured for this connection. Set one with "canonry google set-property".')
51147
+ );
50739
51148
  }
50740
51149
  const invalidUrls = sitemapUrls.filter((url) => !isSitemapOwnedByProperty(url, conn.propertyId, project.canonicalDomain));
50741
51150
  if (invalidUrls.length > 0) {
50742
- throw validationError(`Sitemap URLs must belong to the configured GSC property "${conn.propertyId}". Invalid: ${invalidUrls.slice(0, 5).join(", ")}`);
51151
+ throw outcome2.refuse(
51152
+ OutcomeReasonCodes.VALIDATION,
51153
+ validationError(`Sitemap URLs must belong to the configured GSC property "${conn.propertyId}". Invalid: ${invalidUrls.slice(0, 5).join(", ")}`)
51154
+ );
50743
51155
  }
50744
51156
  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.');
51157
+ throw outcome2.refuse(
51158
+ OutcomeReasonCodes.PERMISSION_MISSING,
51159
+ 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.')
51160
+ );
50746
51161
  }
50747
51162
  const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
50748
51163
  if (!googleClientId || !googleClientSecret) {
50749
- throw validationError("Google OAuth is not configured");
51164
+ throw outcome2.refuse(OutcomeReasonCodes.NOT_CONNECTED, validationError("Google OAuth is not configured"));
50750
51165
  }
50751
51166
  let accessToken;
50752
51167
  try {
50753
51168
  ({ accessToken } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret));
50754
51169
  } catch (err) {
51170
+ outcome2.report({ status: OutcomeStatuses.failed, ...gscOutcomeFailure(err) });
50755
51171
  throw gscErrorToAppError(err, "Failed to authorize Search Console sitemap submission");
50756
51172
  }
50757
51173
  const results = [];
51174
+ let lastSubmitError;
50758
51175
  for (const sitemapUrl of sitemapUrls) {
50759
51176
  try {
50760
51177
  await submitSitemap(accessToken, conn.propertyId, sitemapUrl);
@@ -50768,11 +51185,15 @@ async function googleRoutes(app, opts) {
50768
51185
  });
50769
51186
  results.push({ sitemapUrl, status: "accepted", submittedAt });
50770
51187
  } catch (err) {
51188
+ lastSubmitError = err;
50771
51189
  results.push({ sitemapUrl, status: "error", error: describeError(err) });
50772
51190
  }
50773
51191
  }
50774
51192
  const accepted = results.filter((result) => result.status === "accepted").length;
50775
- return { summary: { total: results.length, accepted, failed: results.length - accepted }, results };
51193
+ const failed = results.length - accepted;
51194
+ const counts2 = { urls: accepted, failures: failed };
51195
+ outcome2.report(failed === 0 ? { status: OutcomeStatuses.succeeded, counts: counts2 } : { status: accepted > 0 ? OutcomeStatuses.partial : OutcomeStatuses.failed, ...gscOutcomeFailure(lastSubmitError), counts: counts2 });
51196
+ return { summary: { total: results.length, accepted, failed }, results };
50776
51197
  });
50777
51198
  app.post("/projects/:name/google/gsc/discover-sitemaps", async (request) => {
50778
51199
  const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
@@ -50866,7 +51287,10 @@ async function googleRoutes(app, opts) {
50866
51287
  }
50867
51288
  return { sitemapUrl: sitemapUrl.trim() };
50868
51289
  });
50869
- app.put("/projects/:name/google/connections/:type/property", async (request) => {
51290
+ app.put("/projects/:name/google/connections/:type/property", connectionRoute(app, (request) => {
51291
+ const integration = googleIntegration(request.params.type);
51292
+ return integration ? { integration, action: "select" } : void 0;
51293
+ }, async (request, _reply, attempt) => {
50870
51294
  const store = requireConnectionStore();
50871
51295
  const project = resolveProject(app.db, request.params.name);
50872
51296
  const { propertyId } = request.body ?? {};
@@ -50879,10 +51303,11 @@ async function googleRoutes(app, opts) {
50879
51303
  { propertyId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
50880
51304
  );
50881
51305
  if (!conn) {
51306
+ attempt.failed(void 0, "NOT_CONNECTED");
50882
51307
  throw notFound("Google connection", request.params.type);
50883
51308
  }
50884
51309
  return { propertyId };
50885
- });
51310
+ }));
50886
51311
  app.post("/projects/:name/google/indexing/request", async (request) => {
50887
51312
  const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
50888
51313
  if (!googleClientId || !googleClientSecret) {
@@ -51197,7 +51622,7 @@ async function googleRoutes(app, opts) {
51197
51622
  }
51198
51623
  return response;
51199
51624
  });
51200
- app.put("/projects/:name/gbp/locations/:locationName/selection", async (request) => {
51625
+ app.put("/projects/:name/gbp/locations/:locationName/selection", connectionRoute(app, () => ({ integration: "gbp", action: "select" }), async (request) => {
51201
51626
  const project = resolveProject(app.db, request.params.name);
51202
51627
  const locationName = decodeURIComponent(request.params.locationName);
51203
51628
  const parsed = gbpLocationSelectionRequestSchema.safeParse(request.body ?? {});
@@ -51220,8 +51645,8 @@ async function googleRoutes(app, opts) {
51220
51645
  });
51221
51646
  const refreshed = app.db.select().from(gbpLocations).where(eq57(gbpLocations.id, existing.id)).get();
51222
51647
  return rowToDto2(refreshed);
51223
- });
51224
- app.delete("/projects/:name/gbp/connection", async (request, reply) => {
51648
+ }));
51649
+ app.delete("/projects/:name/gbp/connection", connectionRoute(app, () => ({ integration: "gbp", action: "disconnect" }), async (request, reply, attempt) => {
51225
51650
  const project = resolveProject(app.db, request.params.name);
51226
51651
  const store = requireConnectionStore();
51227
51652
  app.db.transaction((tx) => {
@@ -51233,9 +51658,9 @@ async function googleRoutes(app, opts) {
51233
51658
  entityType: "gbp_connection"
51234
51659
  });
51235
51660
  });
51236
- store.deleteConnection(project.canonicalDomain, "gbp");
51661
+ if (!store.deleteConnection(project.canonicalDomain, "gbp")) attempt.cancelled("NOT_CONNECTED");
51237
51662
  return reply.status(204).send();
51238
- });
51663
+ }));
51239
51664
  app.post("/projects/:name/gbp/sync", async (request) => {
51240
51665
  const project = resolveProject(app.db, request.params.name);
51241
51666
  const store = requireConnectionStore();
@@ -51500,6 +51925,10 @@ var oauthStateSchema = z4.object({
51500
51925
  }).strict();
51501
51926
  var GOOGLE_MARKETING_OAUTH_PENDING_FLOW_MAX = 256;
51502
51927
  var GOOGLE_MARKETING_OAUTH_COOKIE_PREFIX = "canonry_google_marketing_oauth_";
51928
+ var MARKETING_INTEGRATIONS = { "google-ads": "google_ads", gtm: "gtm" };
51929
+ function marketingConnection(provider, action = "connect") {
51930
+ return typeof provider === "string" && Object.hasOwn(MARKETING_INTEGRATIONS, provider) ? { integration: MARKETING_INTEGRATIONS[provider], action } : void 0;
51931
+ }
51503
51932
  function oauthInitiator(request) {
51504
51933
  const principal = request.principal;
51505
51934
  return {
@@ -52226,7 +52655,7 @@ async function googleMarketingRoutes(app, opts) {
52226
52655
  });
52227
52656
  if (stateSecret) {
52228
52657
  const registerOAuthConnect = (path8, provider) => {
52229
- app.post(path8, async (request, reply) => {
52658
+ app.post(path8, connectionRoute(app, () => marketingConnection(provider), async (request, reply, attempt) => {
52230
52659
  requireWrite(request);
52231
52660
  if (request.principal && !request.principal.viaCookie) {
52232
52661
  throw forbidden("Google Marketing OAuth must be started from a signed-in browser.");
@@ -52240,6 +52669,8 @@ async function googleMarketingRoutes(app, opts) {
52240
52669
  }
52241
52670
  const project = resolveProject(app.db, request.params.name);
52242
52671
  const store = requireCredentialStore(opts);
52672
+ const connectionAction = telemetryRead(() => store.get(asProjectRef(project), provider), void 0) ? "reauth" : "connect";
52673
+ attempt.update({ action: connectionAction });
52243
52674
  const adapter = opts.googleMarketingOAuth;
52244
52675
  if (!adapter) throw notImplemented("Google Marketing OAuth is not configured for this deployment.");
52245
52676
  const scopes = opts.googleMarketingOAuthScopes?.[provider];
@@ -52281,6 +52712,7 @@ async function googleMarketingRoutes(app, opts) {
52281
52712
  expiresAtMs: nowMs + GOOGLE_OAUTH_STATE_MAX_AGE_MS,
52282
52713
  initiator: oauthInitiator(request),
52283
52714
  browserNonce,
52715
+ connectionAction,
52284
52716
  ...suppliedDeveloperToken ? { developerToken: suppliedDeveloperToken } : {}
52285
52717
  }, nowMs);
52286
52718
  appendSetCookie(reply, serializeOAuthBindingCookie({
@@ -52297,28 +52729,34 @@ async function googleMarketingRoutes(app, opts) {
52297
52729
  entityId: provider,
52298
52730
  diff: { provider }
52299
52731
  }));
52732
+ attempt.started();
52300
52733
  return {
52301
52734
  provider,
52302
52735
  authorizationUrl,
52303
52736
  redirectUri,
52304
52737
  expiresAt: null
52305
52738
  };
52306
- });
52739
+ }));
52307
52740
  };
52308
52741
  registerOAuthConnect("/projects/:name/google-ads/oauth/connect", GoogleMarketingProviders["google-ads"]);
52309
52742
  registerOAuthConnect("/projects/:name/gtm/oauth/connect", GoogleMarketingProviders.gtm);
52310
- app.get("/google-marketing/callback", async (request, reply) => {
52743
+ app.get("/google-marketing/callback", connectionRoute(app, (request) => {
52744
+ const verified = oauthStateSchema.safeParse(verifySignedGoogleOAuthState(request.query.state ?? "", stateSecret));
52745
+ return marketingConnection(verified.success ? verified.data.provider : unverifiedOAuthStateField(request.query.state, "provider"));
52746
+ }, async (request, reply, attempt) => {
52311
52747
  if (!request.query.state) {
52312
52748
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Missing OAuth state."));
52313
52749
  }
52314
52750
  const state = verifySignedGoogleOAuthState(request.query.state, stateSecret);
52315
52751
  const parsedState = oauthStateSchema.safeParse(state);
52316
52752
  if (!parsedState.success) {
52753
+ attempt.failed(void 0, "OAUTH_STATE_INVALID");
52317
52754
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Invalid, expired, or tampered OAuth state."));
52318
52755
  }
52319
52756
  const browserNonce = parseCookieHeader(request.headers.cookie)[oauthBindingCookieName(parsedState.data.nonce)];
52320
52757
  const pending = pendingOAuthFlows.consume(parsedState.data, browserNonce);
52321
52758
  if (!pending) {
52759
+ attempt.failed(void 0, "OAUTH_STATE_INVALID");
52322
52760
  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
52761
  }
52324
52762
  appendSetCookie(reply, serializeOAuthBindingCookie({
@@ -52327,39 +52765,48 @@ async function googleMarketingRoutes(app, opts) {
52327
52765
  secure: new URL(parsedState.data.redirectUri).protocol === "https:",
52328
52766
  opts
52329
52767
  }));
52768
+ attempt.update({ action: pending.connectionAction ?? "connect" });
52330
52769
  if (request.query.error) {
52331
52770
  pendingOAuthFlows.finish(pending);
52771
+ if (request.query.error === "access_denied") attempt.cancelled("OAUTH_CANCELLED");
52772
+ else attempt.failed(void 0, "AUTH_DENIED");
52332
52773
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Google did not approve this authorization request."));
52333
52774
  }
52334
52775
  if (!request.query.code) {
52335
52776
  pendingOAuthFlows.finish(pending);
52777
+ attempt.failed(void 0, "VALIDATION");
52336
52778
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Missing OAuth code."));
52337
52779
  }
52338
52780
  const { projectId, projectName, provider, redirectUri } = parsedState.data;
52339
52781
  const project = app.db.select().from(projects).where(eq58(projects.id, projectId)).get();
52340
52782
  if (!project || project.name !== projectName) {
52341
52783
  pendingOAuthFlows.finish(pending);
52784
+ attempt.failed(void 0, project ? "OAUTH_STATE_INVALID" : "NOT_FOUND");
52342
52785
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Project ownership changed. Restart the OAuth flow."));
52343
52786
  }
52344
52787
  const store = opts.googleMarketingCredentialStore;
52345
52788
  const adapter = opts.googleMarketingOAuth;
52346
52789
  if (!store || !adapter) {
52347
52790
  pendingOAuthFlows.finish(pending);
52791
+ attempt.failed(void 0, "UNSUPPORTED");
52348
52792
  return reply.status(500).type("text/html").send(oauthHtml("Authorization failed", "Google Marketing OAuth is not configured."));
52349
52793
  }
52350
52794
  let tokens;
52351
52795
  try {
52352
52796
  tokens = await adapter.exchangeCode({ provider, code: request.query.code, redirectUri });
52353
- } catch {
52797
+ } catch (err) {
52354
52798
  pendingOAuthFlows.finish(pending);
52799
+ attempt.failed(err);
52355
52800
  return reply.status(502).type("text/html").send(oauthHtml("Authorization failed", "Google rejected the OAuth code. Restart the connection flow."));
52356
52801
  }
52357
52802
  if (!pendingOAuthFlows.isCurrent(pending)) {
52358
52803
  pendingOAuthFlows.finish(pending);
52804
+ attempt.failed(void 0, "OAUTH_STATE_INVALID");
52359
52805
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "This OAuth flow was replaced or disconnected. Restart the connection flow."));
52360
52806
  }
52361
52807
  if (typeof tokens.refreshToken !== "string" || tokens.refreshToken.trim().length === 0) {
52362
52808
  pendingOAuthFlows.finish(pending);
52809
+ attempt.failed(void 0, "PERMISSION_MISSING");
52363
52810
  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
52811
  }
52365
52812
  let confirmationId;
@@ -52367,19 +52814,22 @@ async function googleMarketingRoutes(app, opts) {
52367
52814
  confirmationId = pendingOAuthFlows.createConfirmation(pending, tokens);
52368
52815
  } catch {
52369
52816
  pendingOAuthFlows.finish(pending);
52817
+ attempt.failed(void 0, "RATE_LIMITED");
52370
52818
  return reply.status(503).type("text/html").send(oauthHtml("Authorization failed", "Too many pending OAuth confirmations. Restart the connection flow."));
52371
52819
  }
52372
52820
  if (!confirmationId) {
52373
52821
  pendingOAuthFlows.finish(pending);
52822
+ attempt.failed(void 0, "OAUTH_STATE_INVALID");
52374
52823
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "This OAuth flow was replaced or disconnected. Restart the connection flow."));
52375
52824
  }
52825
+ attempt.deferred();
52376
52826
  return reply.type("text/html").send(oauthConfirmationHtml({
52377
52827
  provider,
52378
52828
  projectName: project.name,
52379
52829
  confirmationId,
52380
52830
  opts
52381
52831
  }));
52382
- });
52832
+ }));
52383
52833
  app.post("/google-marketing/callback/confirm/:confirmationId", async (request, reply) => {
52384
52834
  if (request.principal && !request.principal.viaCookie) {
52385
52835
  throw forbidden("Google Marketing OAuth confirmation requires the browser that started the connection.");
@@ -52395,18 +52845,22 @@ async function googleMarketingRoutes(app, opts) {
52395
52845
  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
52846
  }
52397
52847
  const { flow, tokens } = confirmation;
52848
+ const attempt = startConnectionAttempt(app, marketingConnection(flow.provider, flow.connectionAction));
52398
52849
  if (!pendingOAuthFlows.isCurrent(flow)) {
52399
52850
  pendingOAuthFlows.finish(flow);
52851
+ attempt.failed(void 0, "OAUTH_STATE_INVALID");
52400
52852
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "This OAuth flow was replaced or disconnected. Restart the connection flow."));
52401
52853
  }
52402
52854
  const project = app.db.select().from(projects).where(eq58(projects.id, flow.projectId)).get();
52403
52855
  if (!project || project.name !== flow.projectName) {
52404
52856
  pendingOAuthFlows.finish(flow);
52857
+ attempt.failed(void 0, project ? "OAUTH_STATE_INVALID" : "NOT_FOUND");
52405
52858
  return reply.status(400).type("text/html").send(oauthHtml("Authorization failed", "Project ownership changed. Restart the OAuth flow."));
52406
52859
  }
52407
52860
  const store = opts.googleMarketingCredentialStore;
52408
52861
  if (!store) {
52409
52862
  pendingOAuthFlows.finish(flow);
52863
+ attempt.failed(void 0, "UNSUPPORTED");
52410
52864
  return reply.status(500).type("text/html").send(oauthHtml("Authorization failed", "Google Marketing OAuth is not configured."));
52411
52865
  }
52412
52866
  const projectRef = asProjectRef(project);
@@ -52494,6 +52948,7 @@ async function googleMarketingRoutes(app, opts) {
52494
52948
  });
52495
52949
  });
52496
52950
  } catch (error) {
52951
+ attempt.failed(error);
52497
52952
  if (rollbackCredential) {
52498
52953
  try {
52499
52954
  rollbackCredential();
@@ -52505,6 +52960,7 @@ async function googleMarketingRoutes(app, opts) {
52505
52960
  } finally {
52506
52961
  pendingOAuthFlows.finish(flow);
52507
52962
  }
52963
+ attempt.succeeded();
52508
52964
  const label = flow.provider === GoogleMarketingProviders["google-ads"] ? "Google Ads" : "Google Tag Manager";
52509
52965
  return reply.type("text/html").send(oauthHtml("Connected successfully", `${label} is now linked to this project.`));
52510
52966
  });
@@ -52595,16 +53051,24 @@ async function googleMarketingRoutes(app, opts) {
52595
53051
  enqueueAfterCommit(app, callback, run.id, projectId, kind);
52596
53052
  return run.id;
52597
53053
  };
52598
- app.put("/projects/:name/google-ads/selection", async (request) => {
53054
+ app.put("/projects/:name/google-ads/selection", connectionRoute(app, () => marketingConnection(GoogleMarketingProviders["google-ads"], "select"), async (request, _reply, attempt) => {
52599
53055
  requireWrite(request);
52600
53056
  const parsedBody = parseBody3(googleAdsCustomerSelectionRequestSchema, request.body, "Google Ads selection");
52601
53057
  const body = canonicalizeGoogleAdsCustomerSelection(parsedBody);
52602
53058
  if (!body) throw validationError("Invalid Google Ads selection.");
52603
53059
  const project = resolveProject(app.db, request.params.name);
52604
53060
  const projectRef = asProjectRef(project);
52605
- requiredCredential(opts, projectRef, GoogleMarketingProviders["google-ads"]);
53061
+ try {
53062
+ requiredCredential(opts, projectRef, GoogleMarketingProviders["google-ads"]);
53063
+ } catch (err) {
53064
+ attempt.failed(err, "NOT_CONNECTED");
53065
+ throw err;
53066
+ }
52606
53067
  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.");
53068
+ if (!existing) {
53069
+ attempt.failed(void 0, "NOT_CONNECTED");
53070
+ throw validationError("Connect Google Ads before selecting a customer.");
53071
+ }
52608
53072
  const now = (/* @__PURE__ */ new Date()).toISOString();
52609
53073
  const customerChanged = existing.selectedCustomerId !== body.customerId;
52610
53074
  app.db.transaction((tx) => {
@@ -52637,17 +53101,25 @@ async function googleMarketingRoutes(app, opts) {
52637
53101
  });
52638
53102
  queueSelectionSync(request, project.id, "google-ads-sync", opts.onGoogleAdsSyncRequested);
52639
53103
  return googleAdsStatus(app, opts, projectRef);
52640
- });
52641
- app.put("/projects/:name/gtm/selection", async (request) => {
53104
+ }));
53105
+ app.put("/projects/:name/gtm/selection", connectionRoute(app, () => marketingConnection(GoogleMarketingProviders.gtm, "select"), async (request, _reply, attempt) => {
52642
53106
  requireWrite(request);
52643
53107
  const parsedBody = parseBody3(gtmResourceSelectionRequestSchema, request.body, "GTM selection");
52644
53108
  const body = canonicalizeGtmResourceSelection(parsedBody);
52645
53109
  if (!body) throw validationError("Invalid GTM selection.");
52646
53110
  const project = resolveProject(app.db, request.params.name);
52647
53111
  const projectRef = asProjectRef(project);
52648
- requiredCredential(opts, projectRef, GoogleMarketingProviders.gtm);
53112
+ try {
53113
+ requiredCredential(opts, projectRef, GoogleMarketingProviders.gtm);
53114
+ } catch (err) {
53115
+ attempt.failed(err, "NOT_CONNECTED");
53116
+ throw err;
53117
+ }
52649
53118
  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.");
53119
+ if (!existing) {
53120
+ attempt.failed(void 0, "NOT_CONNECTED");
53121
+ throw validationError("Connect Google Tag Manager before selecting resources.");
53122
+ }
52651
53123
  const now = (/* @__PURE__ */ new Date()).toISOString();
52652
53124
  const selectedWorkspaceId = body.workspaceId ?? null;
52653
53125
  const containerChanged = existing.selectedAccountId !== body.accountId || existing.selectedContainerId !== body.containerId;
@@ -52681,7 +53153,7 @@ async function googleMarketingRoutes(app, opts) {
52681
53153
  });
52682
53154
  queueSelectionSync(request, project.id, "gtm-sync", opts.onGtmSyncRequested);
52683
53155
  return gtmStatus(app, opts, projectRef);
52684
- });
53156
+ }));
52685
53157
  app.post("/projects/:name/google-ads/sync", async (request) => {
52686
53158
  requireLiveRead(request);
52687
53159
  requireWrite(request);
@@ -52823,7 +53295,7 @@ async function googleMarketingRoutes(app, opts) {
52823
53295
  if (!snapshot) throw providerError("Stored GTM snapshot is invalid.");
52824
53296
  return { snapshot };
52825
53297
  });
52826
- app.delete("/projects/:name/google-ads/connection", async (request) => {
53298
+ app.delete("/projects/:name/google-ads/connection", connectionRoute(app, () => marketingConnection(GoogleMarketingProviders["google-ads"], "disconnect"), async (request, _reply, attempt) => {
52827
53299
  requireWrite(request);
52828
53300
  const project = resolveProject(app.db, request.params.name);
52829
53301
  const projectRef = asProjectRef(project);
@@ -52859,9 +53331,10 @@ async function googleMarketingRoutes(app, opts) {
52859
53331
  }));
52860
53332
  });
52861
53333
  }
53334
+ if (!row && !removedCredential) attempt.cancelled("NOT_CONNECTED");
52862
53335
  return { provider: GoogleMarketingProviders["google-ads"], disconnected: Boolean(row) || removedCredential };
52863
- });
52864
- app.delete("/projects/:name/gtm/connection", async (request) => {
53336
+ }));
53337
+ app.delete("/projects/:name/gtm/connection", connectionRoute(app, () => marketingConnection(GoogleMarketingProviders.gtm, "disconnect"), async (request, _reply, attempt) => {
52865
53338
  requireWrite(request);
52866
53339
  const project = resolveProject(app.db, request.params.name);
52867
53340
  const projectRef = asProjectRef(project);
@@ -52895,8 +53368,9 @@ async function googleMarketingRoutes(app, opts) {
52895
53368
  }));
52896
53369
  });
52897
53370
  }
53371
+ if (!row && !removedCredential) attempt.cancelled("NOT_CONNECTED");
52898
53372
  return { provider: GoogleMarketingProviders.gtm, disconnected: Boolean(row) || removedCredential };
52899
- });
53373
+ }));
52900
53374
  app.get("/projects/:name/conversion-tracking/options", async (request) => {
52901
53375
  const project = resolveProject(app.db, request.params.name);
52902
53376
  const adsSnapshot = latestGoogleAdsSnapshot(app, project.id);
@@ -54912,6 +55386,31 @@ function enforceAdsActivationOperationalCap(manifest) {
54912
55386
  );
54913
55387
  }
54914
55388
  }
55389
+ var ACTIVATION_FAILURE_REASONS = {
55390
+ [AdsActivationErrorCodes.invalidManifest]: OutcomeReasonCodes.VALIDATION,
55391
+ [AdsActivationErrorCodes.grantNotFound]: OutcomeReasonCodes.NOT_FOUND,
55392
+ [AdsActivationErrorCodes.grantProjectMismatch]: OutcomeReasonCodes.GATE_REFUSED,
55393
+ [AdsActivationErrorCodes.grantAccountMismatch]: OutcomeReasonCodes.GATE_REFUSED,
55394
+ [AdsActivationErrorCodes.grantHashMismatch]: OutcomeReasonCodes.GATE_REFUSED,
55395
+ [AdsActivationErrorCodes.grantExecutorMismatch]: OutcomeReasonCodes.GATE_REFUSED,
55396
+ [AdsActivationErrorCodes.grantExpired]: OutcomeReasonCodes.GATE_REFUSED,
55397
+ [AdsActivationErrorCodes.grantRevoked]: OutcomeReasonCodes.CANCELLED_BY_USER,
55398
+ [AdsActivationErrorCodes.grantUsed]: OutcomeReasonCodes.GATE_REFUSED,
55399
+ [AdsActivationErrorCodes.operationConflict]: OutcomeReasonCodes.VALIDATION,
55400
+ [AdsActivationErrorCodes.accountNotApproved]: OutcomeReasonCodes.GATE_REFUSED,
55401
+ [AdsActivationErrorCodes.entityMismatch]: OutcomeReasonCodes.VALIDATION,
55402
+ [AdsActivationErrorCodes.entityNotPaused]: OutcomeReasonCodes.VALIDATION,
55403
+ [AdsActivationErrorCodes.entityStale]: OutcomeReasonCodes.VALIDATION,
55404
+ [AdsActivationErrorCodes.adNotApproved]: OutcomeReasonCodes.GATE_REFUSED,
55405
+ [AdsActivationErrorCodes.manualRemediationRequired]: OutcomeReasonCodes.QUARANTINED,
55406
+ [AdsActivationErrorCodes.persistenceFailed]: OutcomeReasonCodes.INTERNAL
55407
+ };
55408
+ function activationFailure(error) {
55409
+ const known = error instanceof AdsActivationError ? ACTIVATION_FAILURE_REASONS[error.code] : void 0;
55410
+ const failure = known ? { ...classifyOutcomeError(error), reasonCode: known } : upstreamFailure(error);
55411
+ const status = failure.reasonCode === OutcomeReasonCodes.QUARANTINED ? OutcomeStatuses.partial : failure.reasonCode === OutcomeReasonCodes.CANCELLED_BY_USER ? OutcomeStatuses.cancelled : OutcomeStatuses.failed;
55412
+ return { status, ...failure };
55413
+ }
54915
55414
  function requireAuthenticatedKey(request) {
54916
55415
  if (!request.apiKey) {
54917
55416
  throw forbidden("Ads activation approval and execution require an authenticated API key");
@@ -54987,21 +55486,39 @@ function terminalActivationResponse(app, opts, grant, operationKey) {
54987
55486
  return activationResponse(opts, canonical.grant, canonical.operation);
54988
55487
  }
54989
55488
  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);
55489
+ const outcome2 = startRouteOutcome(app, FeatureNames.openai_ads, "activation", request ? {} : { trigger: OutcomeTriggers.retry });
55490
+ const store = createActivationStore(app, request);
55491
+ let claimKind;
55492
+ try {
55493
+ const runtime = await opts.resolveRuntime(project);
55494
+ const result = await executeApprovedAdsActivation({
55495
+ store: {
55496
+ ...store,
55497
+ claimGrantAndOperation: async (input) => {
55498
+ const claim = await store.claimGrantAndOperation(input);
55499
+ claimKind = claim.kind;
55500
+ return claim;
55501
+ }
55502
+ },
55503
+ provider: runtime.provider,
55504
+ leaseMs: opts.leaseMs ?? ACTIVATION_LEASE_MS
55505
+ }, {
55506
+ projectId: project.id,
55507
+ adAccountId: runtime.adAccountId,
55508
+ operationKey,
55509
+ grantId: grant.id,
55510
+ manifestHash: grant.manifestHash,
55511
+ executorApiKeyId,
55512
+ manifest: grant.manifest
55513
+ });
55514
+ if (claimKind === "claimed" || claimKind === "resumed") {
55515
+ outcome2.report({ status: OutcomeStatuses.succeeded, counts: { campaigns: 1, operations: result.steps.length } });
55516
+ }
55517
+ return activationResponse(opts, result.grant, result.operation);
55518
+ } catch (error) {
55519
+ outcome2.report(activationFailure(error));
55520
+ throw error;
55521
+ }
55005
55522
  }
55006
55523
  function selectWatchdogOperations(app, nowIso, batchSize, allowLiveRevocationLease) {
55007
55524
  const leaseAvailable = or15(
@@ -55356,7 +55873,8 @@ function registerAdsActivationRoutes(app, opts) {
55356
55873
  const approver = requireAuthenticatedKey(request);
55357
55874
  const project = resolveProject(app.db, request.params.name);
55358
55875
  const body = parseBody4(adsActivationGrantCreateRequestSchema, request.body);
55359
- enforceAdsActivationOperationalCap(body.manifest);
55876
+ const outcome2 = startRouteOutcome(app, FeatureNames.openai_ads, "activation");
55877
+ outcome2.guard(OutcomeReasonCodes.GATE_REFUSED, () => enforceAdsActivationOperationalCap(body.manifest));
55360
55878
  const now = /* @__PURE__ */ new Date();
55361
55879
  const expiresAt = Date.parse(body.expiresAt);
55362
55880
  if (!Number.isFinite(expiresAt) || expiresAt <= now.getTime() || expiresAt > now.getTime() + MAX_ACTIVATION_GRANT_LIFETIME_MS) {
@@ -55375,7 +55893,13 @@ function registerAdsActivationRoutes(app, opts) {
55375
55893
  if (!executor.scopes.includes("*") && !executor.scopes.includes(ADS_ACTIVATE_SCOPE)) {
55376
55894
  throw forbidden(`The executor API key requires the "${ADS_ACTIVATE_SCOPE}" scope`);
55377
55895
  }
55378
- const runtime = await opts.resolveRuntime(project);
55896
+ let runtime;
55897
+ try {
55898
+ runtime = await opts.resolveRuntime(project);
55899
+ } catch (error) {
55900
+ outcome2.report(activationFailure(error));
55901
+ throw error;
55902
+ }
55379
55903
  let preflight;
55380
55904
  try {
55381
55905
  preflight = body.versionPolicy === AdsActivationVersionPolicies.refreshSemanticallyUnchanged ? await refreshSemanticallyUnchangedAdsActivationManifest(runtime.provider, {
@@ -55387,6 +55911,7 @@ function registerAdsActivationRoutes(app, opts) {
55387
55911
  manifest: body.manifest
55388
55912
  });
55389
55913
  } catch (error) {
55914
+ outcome2.report(activationFailure(error));
55390
55915
  if (error instanceof AdsActivationError) throw mapApprovalPreflightError(error);
55391
55916
  throw error;
55392
55917
  }
@@ -55495,6 +56020,9 @@ function registerAdsActivationRoutes(app, opts) {
55495
56020
  if (grant.revocationRequestedAt !== null && (grant.state === AdsActivationGrantStates.executing || grant.state === AdsActivationGrantStates.unknown)) {
55496
56021
  startWatchdogSweep?.();
55497
56022
  }
56023
+ if (existing.state === AdsActivationGrantStates.approved && grant.state === AdsActivationGrantStates.revoked) {
56024
+ startRouteOutcome(app, FeatureNames.openai_ads, "activation").report({ status: OutcomeStatuses.cancelled, reasonCode: OutcomeReasonCodes.CANCELLED_BY_USER });
56025
+ }
55498
56026
  return adsActivationGrantResponseSchema.parse({ grant });
55499
56027
  }
55500
56028
  );
@@ -56268,15 +56796,21 @@ function storedEntityState(row) {
56268
56796
  };
56269
56797
  }
56270
56798
  async function executeAdsOperation(app, request, input) {
56799
+ const outcome2 = startRouteOutcome(app, FeatureNames.openai_ads, "operation");
56271
56800
  const hash2 = requestHash({ kind: input.kind, entityType: input.entityType, payload: input.payload });
56272
56801
  const existing = readOperationByKey(app, input.projectId, input.operationKey);
56273
56802
  if (existing) {
56274
56803
  if (existing.requestHash !== hash2) {
56275
- throw alreadyExists("Ads operation key", input.operationKey);
56804
+ throw outcome2.refuse(OutcomeReasonCodes.VALIDATION, alreadyExists("Ads operation key", input.operationKey));
56276
56805
  }
56277
56806
  return { operation: operationDto(existing), replayed: true };
56278
56807
  }
56279
- await input.preflight?.();
56808
+ try {
56809
+ await input.preflight?.();
56810
+ } catch (err) {
56811
+ outcome2.report({ status: OutcomeStatuses.failed, ...upstreamFailure(err) });
56812
+ throw err;
56813
+ }
56280
56814
  const id = crypto39.randomUUID();
56281
56815
  const createdAt = (/* @__PURE__ */ new Date()).toISOString();
56282
56816
  const reconcileFields = input.reconciliation.fields ?? null;
@@ -56308,10 +56842,10 @@ async function executeAdsOperation(app, request, input) {
56308
56842
  eq60(adsOperations.operationKey, input.operationKey)
56309
56843
  )).get();
56310
56844
  if (!canonical) {
56311
- throw providerError("Ads operation receipt could not be claimed");
56845
+ throw outcome2.refuse(OutcomeReasonCodes.INTERNAL, providerError("Ads operation receipt could not be claimed"));
56312
56846
  }
56313
56847
  if (canonical.requestHash !== hash2) {
56314
- throw alreadyExists("Ads operation key", input.operationKey);
56848
+ throw outcome2.refuse(OutcomeReasonCodes.VALIDATION, alreadyExists("Ads operation key", input.operationKey));
56315
56849
  }
56316
56850
  if (inserted.length === 0) {
56317
56851
  return { operation: operationDto(canonical), replayed: true };
@@ -56360,6 +56894,7 @@ async function executeAdsOperation(app, request, input) {
56360
56894
  }));
56361
56895
  }
56362
56896
  });
56897
+ outcome2.report({ status: OutcomeStatuses.succeeded });
56363
56898
  const row = app.db.select().from(adsOperations).where(eq60(adsOperations.id, id)).get();
56364
56899
  return { operation: operationDto(row), replayed: false };
56365
56900
  } catch (err) {
@@ -56390,6 +56925,10 @@ async function executeAdsOperation(app, request, input) {
56390
56925
  }
56391
56926
  return update.changes > 0;
56392
56927
  });
56928
+ outcome2.report({
56929
+ status: failure.state === AdsOperationStates.failed ? OutcomeStatuses.failed : OutcomeStatuses.partial,
56930
+ ...upstreamFailure(err)
56931
+ });
56393
56932
  if (!finalized) {
56394
56933
  const canonical2 = app.db.select().from(adsOperations).where(and50(
56395
56934
  eq60(adsOperations.id, id),
@@ -56605,8 +57144,17 @@ async function reconcileOneAdsOperation(app, input) {
56605
57144
  maxAttempts: Math.min(20, Math.max(1, input.maxAttempts ?? DEFAULT_RECONCILE_MAX_ATTEMPTS))
56606
57145
  };
56607
57146
  const context = input.audit ?? { actor: "system" };
57147
+ const outcome2 = startRouteOutcome(app, FeatureNames.openai_ads, "reconcile", context.request ? {} : { trigger: OutcomeTriggers.scheduled });
57148
+ const reportInProgress = () => outcome2.report({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.OPERATION_IN_PROGRESS });
57149
+ const finished = (row, failure) => {
57150
+ if (row.state === AdsOperationStates.succeeded) outcome2.report({ status: OutcomeStatuses.succeeded });
57151
+ else if (row.errorCode === ADS_RECONCILIATION_QUARANTINED) outcome2.report({ status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.QUARANTINED });
57152
+ else outcome2.report({ status: OutcomeStatuses.failed, ...failure });
57153
+ return reconcileResponse(row);
57154
+ };
56608
57155
  const pendingWaitMs = existing.state === AdsOperationStates.pending ? pendingIdleRemainingMs(existing, now, policy.pendingMinIdleMs) : 0;
56609
57156
  if (pendingWaitMs > 0) {
57157
+ reportInProgress();
56610
57158
  throw operationInProgress(
56611
57159
  "The ads mutation may still be in flight; reconciliation is blocked until the receipt is idle",
56612
57160
  {
@@ -56617,7 +57165,14 @@ async function reconcileOneAdsOperation(app, input) {
56617
57165
  );
56618
57166
  }
56619
57167
  if (existing.reconcileAttempts >= policy.maxAttempts) {
56620
- return reconcileResponse(quarantineExhaustedReconciliation(app, existing, now, policy, context));
57168
+ const row = quarantineExhaustedReconciliation(app, existing, now, policy, context);
57169
+ if (row.errorCode !== ADS_RECONCILIATION_QUARANTINED) {
57170
+ reportInProgress();
57171
+ } else {
57172
+ const already = existing.errorCode === ADS_RECONCILIATION_QUARANTINED;
57173
+ outcome2.report({ status: already ? OutcomeStatuses.skipped : OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.QUARANTINED });
57174
+ }
57175
+ return reconcileResponse(row);
56621
57176
  }
56622
57177
  const leaseOwner = `ads-reconcile:${crypto39.randomUUID()}`;
56623
57178
  const claimed = claimOperationForReconciliation(
@@ -56635,6 +57190,7 @@ async function reconcileOneAdsOperation(app, input) {
56635
57190
  if (canonical.state === AdsOperationStates.pending) {
56636
57191
  const retryAfterMs = pendingIdleRemainingMs(canonical, now, policy.pendingMinIdleMs);
56637
57192
  if (retryAfterMs > 0) {
57193
+ reportInProgress();
56638
57194
  throw operationInProgress(
56639
57195
  "The ads mutation may still be in flight; reconciliation is blocked until the receipt is idle",
56640
57196
  {
@@ -56645,6 +57201,7 @@ async function reconcileOneAdsOperation(app, input) {
56645
57201
  );
56646
57202
  }
56647
57203
  }
57204
+ reportInProgress();
56648
57205
  return {
56649
57206
  operation: operationDto(canonical),
56650
57207
  resolved: canonical.state === AdsOperationStates.succeeded
@@ -56660,7 +57217,9 @@ async function reconcileOneAdsOperation(app, input) {
56660
57217
  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
57218
  policy.maxAttempts
56662
57219
  );
56663
- return reconcileResponse(row);
57220
+ return finished(row, {
57221
+ reasonCode: claimed.adAccountId ? OutcomeReasonCodes.ACCOUNT_NOT_FOUND : OutcomeReasonCodes.UNSUPPORTED
57222
+ });
56664
57223
  }
56665
57224
  const strategy = claimed.reconcileStrategy;
56666
57225
  const desired = claimed.reconcileFields;
@@ -56674,7 +57233,7 @@ async function reconcileOneAdsOperation(app, input) {
56674
57233
  "This operation cannot be reconciled automatically",
56675
57234
  policy.maxAttempts
56676
57235
  );
56677
- return reconcileResponse(row);
57236
+ return finished(row, { reasonCode: OutcomeReasonCodes.UNSUPPORTED });
56678
57237
  }
56679
57238
  try {
56680
57239
  let match;
@@ -56689,7 +57248,7 @@ async function reconcileOneAdsOperation(app, input) {
56689
57248
  "The create outcome has no checkpointed provider id and cannot be resolved automatically",
56690
57249
  policy.maxAttempts
56691
57250
  );
56692
- return reconcileResponse(row2);
57251
+ return finished(row2, { reasonCode: OutcomeReasonCodes.UNSUPPORTED });
56693
57252
  }
56694
57253
  if (checkpointedId) {
56695
57254
  const entity = await getEntityForReconciliation(
@@ -56718,13 +57277,13 @@ async function reconcileOneAdsOperation(app, input) {
56718
57277
  "The upstream entity did not match the requested safe state",
56719
57278
  policy.maxAttempts
56720
57279
  );
56721
- return reconcileResponse(row2);
57280
+ return finished(row2, { reasonCode: OutcomeReasonCodes.UNKNOWN });
56722
57281
  }
56723
57282
  const row = finishReconciliation(app, claimed, leaseOwner, context, {
56724
57283
  state: AdsOperationStates.succeeded,
56725
57284
  entity: match
56726
57285
  }, policy.maxAttempts);
56727
- return reconcileResponse(row);
57286
+ return finished(row, { reasonCode: OutcomeReasonCodes.UNKNOWN });
56728
57287
  } catch (error) {
56729
57288
  const failure = errorDetails(error);
56730
57289
  const row = unknownReconciliation(
@@ -56736,7 +57295,7 @@ async function reconcileOneAdsOperation(app, input) {
56736
57295
  "OpenAI Ads API outcome could not be reconciled",
56737
57296
  policy.maxAttempts
56738
57297
  );
56739
- return reconcileResponse(row);
57298
+ return finished(row, upstreamFailure(error));
56740
57299
  }
56741
57300
  }
56742
57301
  async function sweepAdsOperationsOnce(app, opts, config) {
@@ -56808,7 +57367,10 @@ async function sweepAdsOperationsOnce(app, opts, config) {
56808
57367
  )
56809
57368
  )).orderBy(asc12(adsOperations.updatedAt)).limit(config.batchSize).all();
56810
57369
  for (const row of exhaustedRows) {
56811
- quarantineExhaustedReconciliation(app, row, now, config.policy, { actor: "system" });
57370
+ const settled = quarantineExhaustedReconciliation(app, row, now, config.policy, { actor: "system" });
57371
+ if (settled.errorCode === ADS_RECONCILIATION_QUARANTINED) {
57372
+ startRouteOutcome(app, FeatureNames.openai_ads, "reconcile", { trigger: OutcomeTriggers.scheduled }).report({ status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.QUARANTINED });
57373
+ }
56812
57374
  }
56813
57375
  const credentialsByProjectId = /* @__PURE__ */ new Map();
56814
57376
  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 +57564,7 @@ async function adsRoutes(app, opts) {
57002
57564
  });
57003
57565
  app.post(
57004
57566
  "/projects/:name/ads/connect",
57005
- async (request) => {
57567
+ connectionRoute(app, () => ({ integration: "openai_ads", action: "connect" }), async (request, _reply, attempt) => {
57006
57568
  requireScope(request, ADS_WRITE_SCOPE);
57007
57569
  const project = resolveProject(app.db, request.params.name);
57008
57570
  const parsed = adsConnectRequestSchema.safeParse(request.body);
@@ -57014,12 +57576,14 @@ async function adsRoutes(app, opts) {
57014
57576
  try {
57015
57577
  account = await opts.verifyAdsAccount(parsed.data.apiKey);
57016
57578
  } catch (err) {
57579
+ attempt.failed(err);
57017
57580
  const message = describeError(err);
57018
57581
  throw validationError(`OpenAI Ads API rejected the key: ${message}`);
57019
57582
  }
57020
57583
  const now = (/* @__PURE__ */ new Date()).toISOString();
57021
57584
  verificationCache.delete(project.id);
57022
57585
  const existingCfg = opts.adsCredentialStore.getConnection(project.name);
57586
+ if (existingCfg) attempt.update({ action: "reauth" });
57023
57587
  opts.adsCredentialStore.upsertConnection({
57024
57588
  projectName: project.name,
57025
57589
  apiKey: parsed.data.apiKey,
@@ -57071,9 +57635,9 @@ async function adsRoutes(app, opts) {
57071
57635
  });
57072
57636
  const row = app.db.select().from(adsConnections).where(eq60(adsConnections.projectId, project.id)).get();
57073
57637
  return statusDto(row);
57074
- }
57638
+ })
57075
57639
  );
57076
- app.delete("/projects/:name/ads/connection", async (request) => {
57640
+ app.delete("/projects/:name/ads/connection", connectionRoute(app, () => ({ integration: "openai_ads", action: "disconnect" }), async (request, _reply, attempt) => {
57077
57641
  requireScope(request, ADS_WRITE_SCOPE);
57078
57642
  const project = resolveProject(app.db, request.params.name);
57079
57643
  const row = app.db.select().from(adsConnections).where(eq60(adsConnections.projectId, project.id)).get();
@@ -57092,8 +57656,9 @@ async function adsRoutes(app, opts) {
57092
57656
  const removedFromConfig = opts.adsCredentialStore?.removeConnection(project.name) ?? false;
57093
57657
  verificationCache.delete(project.id);
57094
57658
  const response = { disconnected: Boolean(row) || removedFromConfig };
57659
+ if (!response.disconnected) attempt.cancelled("NOT_CONNECTED");
57095
57660
  return response;
57096
- });
57661
+ }));
57097
57662
  app.get("/projects/:name/ads/status", async (request) => {
57098
57663
  const project = resolveProject(app.db, request.params.name);
57099
57664
  const row = app.db.select().from(adsConnections).where(eq60(adsConnections.projectId, project.id)).get();
@@ -58426,7 +58991,7 @@ async function bingRoutes(app, opts) {
58426
58991
  }
58427
58992
  return conn;
58428
58993
  }
58429
- app.post("/projects/:name/bing/connect", async (request) => {
58994
+ app.post("/projects/:name/bing/connect", connectionRoute(app, () => ({ integration: "bing", action: "connect" }), async (request, _reply, attempt) => {
58430
58995
  const store = requireConnectionStore();
58431
58996
  const { apiKey } = request.body ?? {};
58432
58997
  if (!apiKey || typeof apiKey !== "string") {
@@ -58440,11 +59005,14 @@ async function bingRoutes(app, opts) {
58440
59005
  } catch (e) {
58441
59006
  const msg = describeError(e);
58442
59007
  bingLog("error", "connect.verify-key-failed", { domain: project.canonicalDomain, error: msg });
59008
+ attempt.failed(e);
58443
59009
  throw validationError(`Failed to verify Bing API key: ${msg}`);
58444
59010
  }
58445
59011
  const now = (/* @__PURE__ */ new Date()).toISOString();
58446
59012
  const existing = store.getConnection(project.canonicalDomain);
59013
+ if (existing) attempt.update({ action: "reauth" });
58447
59014
  if (existing && existing.createdByProjectId && existing.createdByProjectId !== project.id) {
59015
+ attempt.failed(void 0, "ALREADY_CONNECTED");
58448
59016
  throw validationError(
58449
59017
  `This domain already has a Bing connection owned by another project. Disconnect it from that project first before re-connecting from "${project.name}".`
58450
59018
  );
@@ -58470,12 +59038,13 @@ async function bingRoutes(app, opts) {
58470
59038
  siteUrl: existing?.siteUrl ?? null,
58471
59039
  availableSites: sites.map((s) => ({ url: s.Url, verified: s.IsVerified ?? false }))
58472
59040
  };
58473
- });
58474
- app.delete("/projects/:name/bing/disconnect", async (request, reply) => {
59041
+ }));
59042
+ app.delete("/projects/:name/bing/disconnect", connectionRoute(app, () => ({ integration: "bing", action: "disconnect" }), async (request, reply, attempt) => {
58475
59043
  const store = requireConnectionStore();
58476
59044
  const project = resolveProject(app.db, request.params.name);
58477
59045
  const existing = store.getConnection(project.canonicalDomain);
58478
59046
  if (!existing) {
59047
+ attempt.failed(void 0, "NOT_CONNECTED");
58479
59048
  throw notFound("Bing connection", project.canonicalDomain);
58480
59049
  }
58481
59050
  if (existing.createdByProjectId && existing.createdByProjectId !== project.id) {
@@ -58485,6 +59054,7 @@ async function bingRoutes(app, opts) {
58485
59054
  }
58486
59055
  const deleted = store.deleteConnection(project.canonicalDomain);
58487
59056
  if (!deleted) {
59057
+ attempt.failed(void 0, "NOT_CONNECTED");
58488
59058
  throw notFound("Bing connection", project.canonicalDomain);
58489
59059
  }
58490
59060
  writeAuditLog(app.db, {
@@ -58495,7 +59065,7 @@ async function bingRoutes(app, opts) {
58495
59065
  entityId: project.canonicalDomain
58496
59066
  });
58497
59067
  return reply.status(204).send();
58498
- });
59068
+ }));
58499
59069
  app.get("/projects/:name/bing/status", async (request) => {
58500
59070
  const store = requireConnectionStore();
58501
59071
  const project = resolveProject(app.db, request.params.name);
@@ -58516,10 +59086,15 @@ async function bingRoutes(app, opts) {
58516
59086
  const sites = await getSites(conn.apiKey);
58517
59087
  return { sites: sites.map((s) => ({ url: s.Url, verified: s.IsVerified ?? false })) };
58518
59088
  });
58519
- app.post("/projects/:name/bing/set-site", async (request) => {
59089
+ app.post("/projects/:name/bing/set-site", connectionRoute(app, () => ({ integration: "bing", action: "select" }), async (request, _reply, attempt) => {
58520
59090
  const store = requireConnectionStore();
58521
59091
  const project = resolveProject(app.db, request.params.name);
58522
- requireConnection(store, project.canonicalDomain);
59092
+ try {
59093
+ requireConnection(store, project.canonicalDomain);
59094
+ } catch (err) {
59095
+ attempt.failed(err, "NOT_CONNECTED");
59096
+ throw err;
59097
+ }
58523
59098
  const { siteUrl } = request.body ?? {};
58524
59099
  if (!siteUrl || typeof siteUrl !== "string") {
58525
59100
  throw validationError("siteUrl is required");
@@ -58529,7 +59104,7 @@ async function bingRoutes(app, opts) {
58529
59104
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
58530
59105
  });
58531
59106
  return { siteUrl };
58532
- });
59107
+ }));
58533
59108
  app.get("/projects/:name/bing/coverage", async (request) => {
58534
59109
  const store = requireConnectionStore();
58535
59110
  const project = resolveProject(app.db, request.params.name);
@@ -58653,9 +59228,13 @@ async function bingRoutes(app, opts) {
58653
59228
  app.post("/projects/:name/bing/inspect-url", async (request) => {
58654
59229
  const store = requireConnectionStore();
58655
59230
  const project = resolveProject(app.db, request.params.name);
58656
- const conn = requireConnection(store, project.canonicalDomain);
59231
+ const outcome2 = startRouteOutcome(app, FeatureNames.bing, "inspect");
59232
+ const conn = outcome2.guard(OutcomeReasonCodes.NOT_CONNECTED, () => requireConnection(store, project.canonicalDomain));
58657
59233
  if (!conn.siteUrl) {
58658
- throw validationError('No Bing site configured. Run "canonry bing set-site <project> <url>" first.');
59234
+ throw outcome2.refuse(
59235
+ OutcomeReasonCodes.PROPERTY_NOT_FOUND,
59236
+ validationError('No Bing site configured. Run "canonry bing set-site <project> <url>" first.')
59237
+ );
58659
59238
  }
58660
59239
  const { url } = request.body ?? {};
58661
59240
  if (!url) {
@@ -58726,6 +59305,7 @@ async function bingRoutes(app, opts) {
58726
59305
  discoveryDate
58727
59306
  }).run();
58728
59307
  app.db.update(runs).set({ status: RunStatuses.completed, finishedAt: now }).where(eq61(runs.id, runId)).run();
59308
+ outcome2.report({ status: OutcomeStatuses.succeeded, counts: { urls: 1 } });
58729
59309
  return {
58730
59310
  id,
58731
59311
  url,
@@ -58742,6 +59322,12 @@ async function bingRoutes(app, opts) {
58742
59322
  const msg = describeError(e);
58743
59323
  bingLog("error", "inspect-url.failed", { domain: project.canonicalDomain, url, error: msg });
58744
59324
  app.db.update(runs).set({ status: RunStatuses.failed, error: msg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq61(runs.id, runId)).run();
59325
+ const failure = classifyOutcomeError(e);
59326
+ outcome2.report({
59327
+ status: OutcomeStatuses.failed,
59328
+ ...failure,
59329
+ ...e instanceof BingApiError && e.isThrottle ? { reasonCode: OutcomeReasonCodes.RATE_LIMITED } : {}
59330
+ });
58745
59331
  throw e;
58746
59332
  }
58747
59333
  });
@@ -58893,35 +59479,48 @@ async function cdpRoutes(app, opts) {
58893
59479
  const stream = fs.createReadStream(fullPath);
58894
59480
  return reply.type("image/png").send(stream);
58895
59481
  });
58896
- app.put("/settings/cdp", async (request, reply) => {
59482
+ app.put("/settings/cdp", connectionRoute(app, () => ({ integration: "cdp", action: "connect" }), async (request, reply, attempt) => {
58897
59483
  requireScope(request, SETTINGS_WRITE_SCOPE);
58898
59484
  if (!opts.onCdpConfigure) {
58899
59485
  const err = notImplemented("CDP configuration not supported in this deployment");
59486
+ attempt.failed(err);
58900
59487
  return reply.code(err.statusCode).send(err.toJSON());
58901
59488
  }
58902
59489
  const { host, port = 9222 } = request.body;
58903
59490
  if (!host || typeof host !== "string") {
58904
59491
  const err = validationError("host is required");
59492
+ attempt.failed(err);
58905
59493
  return reply.code(err.statusCode).send(err.toJSON());
58906
59494
  }
58907
59495
  const ALLOWED_HOSTS = ["localhost", "127.0.0.1", "::1"];
58908
59496
  if (!ALLOWED_HOSTS.includes(host)) {
58909
59497
  const err = validationError("host must be localhost, 127.0.0.1, or ::1");
59498
+ attempt.failed(err, "BLOCKED_UNSAFE_URL");
58910
59499
  return reply.code(err.statusCode).send(err.toJSON());
58911
59500
  }
58912
59501
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
58913
59502
  const err = validationError("port must be an integer between 1 and 65535");
59503
+ attempt.failed(err);
58914
59504
  return reply.code(err.statusCode).send(err.toJSON());
58915
59505
  }
58916
59506
  await opts.onCdpConfigure(host, port);
58917
59507
  return reply.code(200).send({ endpoint: `ws://${host}:${port}` });
58918
- });
59508
+ }));
58919
59509
  app.get("/cdp/status", async (_request, reply) => {
58920
59510
  if (!opts.getCdpStatus) {
58921
59511
  const err = notImplemented("CDP not configured");
58922
59512
  return reply.code(err.statusCode).send(err.toJSON());
58923
59513
  }
58924
- const status = await opts.getCdpStatus();
59514
+ const attempt = startConnectionAttempt(app, { integration: "cdp", action: "test" });
59515
+ let status;
59516
+ try {
59517
+ status = await opts.getCdpStatus();
59518
+ } catch (err) {
59519
+ attempt.failed(err);
59520
+ throw err;
59521
+ }
59522
+ if (status.connected) attempt.succeeded();
59523
+ else if (status.endpoint) attempt.failed(void 0, "NETWORK");
58925
59524
  return reply.send(status);
58926
59525
  });
58927
59526
  app.post("/cdp/screenshot", async (request, reply) => {
@@ -59546,6 +60145,17 @@ async function resolveGa4AccessToken(opts, projectName, canonicalDomain) {
59546
60145
  });
59547
60146
  return { accessToken, propertyId: oauthConn.propertyId };
59548
60147
  }
60148
+ function ga4SyncFailure(err, credentialsResolved, opts, project) {
60149
+ const classified = classifyOutcomeError(err);
60150
+ if (credentialsResolved) {
60151
+ return err instanceof GA4ApiError && err.status === 404 ? { ...classified, reasonCode: OutcomeReasonCodes.PROPERTY_NOT_FOUND } : classified;
60152
+ }
60153
+ if (!(err instanceof AppError)) return credentialFailure(err);
60154
+ const oauth = opts.googleConnectionStore?.getConnection(project.canonicalDomain, "ga4");
60155
+ const serviceAccount = opts.ga4CredentialStore?.getConnection(project.name);
60156
+ const unselected = Boolean(oauth?.refreshToken && !oauth.propertyId) || Boolean(serviceAccount?.clientEmail && !serviceAccount.propertyId);
60157
+ return { ...classified, reasonCode: unselected ? OutcomeReasonCodes.PROPERTY_NOT_FOUND : OutcomeReasonCodes.NOT_CONNECTED };
60158
+ }
59549
60159
  function requireGa4Connection(opts, projectName, canonicalDomain) {
59550
60160
  const saConn = opts.ga4CredentialStore?.getConnection(projectName);
59551
60161
  const oauthConn = opts.googleConnectionStore?.getConnection(canonicalDomain, "ga4");
@@ -59554,7 +60164,10 @@ function requireGa4Connection(opts, projectName, canonicalDomain) {
59554
60164
  }
59555
60165
  }
59556
60166
  async function ga4Routes(app, opts) {
59557
- app.post("/projects/:name/ga/connect", async (request, _reply) => {
60167
+ app.post("/projects/:name/ga/connect", connectionRoute(app, (request) => {
60168
+ const body = request.body;
60169
+ return { integration: "ga4", action: typeof body?.keyJson === "string" && body.keyJson ? "connect" : "select" };
60170
+ }, async (request, _reply, attempt) => {
59558
60171
  const project = resolveProject(app.db, request.params.name);
59559
60172
  const { propertyId, keyJson } = request.body ?? {};
59560
60173
  if (!propertyId || typeof propertyId !== "string") {
@@ -59581,10 +60194,12 @@ async function ga4Routes(app, opts) {
59581
60194
  } catch (e) {
59582
60195
  const msg = describeError(e);
59583
60196
  gaLog("error", "connect.verify-failed", { projectId: project.id, propertyId, error: msg });
60197
+ attempt.failed(e);
59584
60198
  throw validationError(`Failed to verify GA4 credentials: ${msg}`);
59585
60199
  }
59586
60200
  const now = (/* @__PURE__ */ new Date()).toISOString();
59587
60201
  const existing = opts.ga4CredentialStore.getConnection(project.name);
60202
+ if (existing) attempt.update({ action: "reauth" });
59588
60203
  const previousPropertyId2 = resolveCurrentGa4PropertyId(opts, project.name, project.canonicalDomain);
59589
60204
  opts.ga4CredentialStore.upsertConnection({
59590
60205
  projectName: project.name,
@@ -59616,6 +60231,7 @@ async function ga4Routes(app, opts) {
59616
60231
  }
59617
60232
  const oauthConn = googleStore.getConnection(project.canonicalDomain, "ga4");
59618
60233
  if (!oauthConn?.accessToken || !oauthConn?.refreshToken) {
60234
+ attempt.failed(void 0, "NOT_CONNECTED");
59619
60235
  throw validationError(
59620
60236
  'No GA4 OAuth token found. Run "canonry google connect <project> --type ga4" first, or pass --key-file to use a service account.'
59621
60237
  );
@@ -59631,6 +60247,7 @@ async function ga4Routes(app, opts) {
59631
60247
  } catch (e) {
59632
60248
  const msg = describeError(e);
59633
60249
  gaLog("error", "connect.verify-failed.oauth", { projectId: project.id, propertyId, error: msg });
60250
+ attempt.failed(e);
59634
60251
  throw validationError(`Failed to verify GA4 access: ${msg}`);
59635
60252
  }
59636
60253
  const previousPropertyId = resolveCurrentGa4PropertyId(opts, project.name, project.canonicalDomain);
@@ -59651,12 +60268,13 @@ async function ga4Routes(app, opts) {
59651
60268
  entityId: propertyId
59652
60269
  });
59653
60270
  return { connected: true, propertyId, authMethod: "oauth" };
59654
- });
59655
- app.delete("/projects/:name/ga/disconnect", async (request, reply) => {
60271
+ }));
60272
+ app.delete("/projects/:name/ga/disconnect", connectionRoute(app, () => ({ integration: "ga4", action: "disconnect" }), async (request, reply, attempt) => {
59656
60273
  const project = resolveProject(app.db, request.params.name);
59657
60274
  const saConn = opts.ga4CredentialStore?.getConnection(project.name);
59658
60275
  const oauthConn = opts.googleConnectionStore?.getConnection(project.canonicalDomain, "ga4");
59659
60276
  if (!saConn && !oauthConn) {
60277
+ attempt.failed(void 0, "NOT_CONNECTED");
59660
60278
  throw notFound("GA4 connection", project.name);
59661
60279
  }
59662
60280
  app.db.delete(gaTrafficSnapshots).where(eq64(gaTrafficSnapshots.projectId, project.id)).run();
@@ -59676,7 +60294,7 @@ async function ga4Routes(app, opts) {
59676
60294
  entityId: propertyId
59677
60295
  });
59678
60296
  return reply.status(204).send();
59679
- });
60297
+ }));
59680
60298
  app.get("/projects/:name/ga/properties", async (request) => {
59681
60299
  assertNotProjectScoped(request, "listing GA4 properties");
59682
60300
  const project = resolveProject(app.db, request.params.name);
@@ -59750,8 +60368,12 @@ async function ga4Routes(app, opts) {
59750
60368
  startedAt,
59751
60369
  createdAt: startedAt
59752
60370
  }).run();
60371
+ const outcome2 = startRouteOutcome(app, FeatureNames.ga4, "sync");
60372
+ let credentialsResolved = false;
60373
+ let componentFailure;
59753
60374
  try {
59754
60375
  const { accessToken, propertyId } = await resolveGa4AccessToken(opts, project.name, project.canonicalDomain);
60376
+ credentialsResolved = true;
59755
60377
  let rows = [];
59756
60378
  let aiReferrals = [];
59757
60379
  let socialReferrals = [];
@@ -59924,6 +60546,7 @@ async function ga4Routes(app, opts) {
59924
60546
  const [acquisitionSettled, leadsSettled, searchLandingSettled] = await measurementFetch;
59925
60547
  const measurementNow = (/* @__PURE__ */ new Date()).toISOString();
59926
60548
  const recordMeasurementFailure = (component, reason) => {
60549
+ componentFailure ??= { error: reason };
59927
60550
  const message = describeError(reason);
59928
60551
  gaLog("warn", "measurement.component-failed", {
59929
60552
  projectId: project.id,
@@ -59989,6 +60612,7 @@ async function ga4Routes(app, opts) {
59989
60612
  }
59990
60613
  })();
59991
60614
  const recordSearchLandingFailure = (status, reason) => {
60615
+ if (status === "error") componentFailure ??= { error: reason };
59992
60616
  const message = describeError(reason);
59993
60617
  gaLog("warn", "search-landing.component-failed", {
59994
60618
  projectId: project.id,
@@ -60047,6 +60671,14 @@ async function ga4Routes(app, opts) {
60047
60671
  totalUsers: summary3.totalUsers,
60048
60672
  ...only ? { only } : {}
60049
60673
  });
60674
+ const counts2 = { rows: rows.length + dailyTotals.length + aiReferrals.length + socialReferrals.length };
60675
+ if (componentFailure) {
60676
+ outcome2.report({ status: OutcomeStatuses.partial, ...classifyOutcomeError(componentFailure.error), counts: counts2 });
60677
+ } else if (rows.length === 0 && dailyTotals.length === 0) {
60678
+ outcome2.report({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.NO_DATA, counts: counts2 });
60679
+ } else {
60680
+ outcome2.report({ status: OutcomeStatuses.succeeded, counts: counts2 });
60681
+ }
60050
60682
  return {
60051
60683
  synced: true,
60052
60684
  rowCount: rows.length,
@@ -60067,6 +60699,7 @@ async function ga4Routes(app, opts) {
60067
60699
  const msg = describeError(e);
60068
60700
  gaLog("error", "sync.fetch-failed", { projectId: project.id, runId, error: msg });
60069
60701
  app.db.update(runs).set({ status: RunStatuses.failed, error: msg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq64(runs.id, runId)).run();
60702
+ outcome2.report({ status: OutcomeStatuses.failed, ...ga4SyncFailure(e, credentialsResolved, opts, project) });
60070
60703
  throw e;
60071
60704
  }
60072
60705
  });
@@ -61706,16 +62339,43 @@ function toAppError(error) {
61706
62339
  return providerError(error.message, { statusCode: error.statusCode });
61707
62340
  }
61708
62341
  }
61709
- async function withWordpressErrorHandling(handler) {
62342
+ function wordpressFailureReason(error) {
62343
+ if (error instanceof EgressRefusedError) return "BLOCKED_UNSAFE_URL";
62344
+ if (error instanceof EgressFailedError) return "NETWORK";
62345
+ return void 0;
62346
+ }
62347
+ async function withWordpressErrorHandling(handler, attempt) {
61710
62348
  try {
61711
62349
  return await handler();
61712
62350
  } catch (error) {
62351
+ attempt?.failed(error, wordpressFailureReason(error));
61713
62352
  if (error instanceof WordpressApiError) throw toAppError(error);
61714
62353
  if (error instanceof EgressRefusedError && !error.redirected) throw validationError(error.message);
61715
62354
  if (error instanceof EgressRefusedError || error instanceof EgressFailedError) throw providerError(error.message);
61716
62355
  throw error;
61717
62356
  }
61718
62357
  }
62358
+ var MANUAL_ASSIST_OUTCOME = { status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.UNSUPPORTED };
62359
+ function metaWriteOutcome(results) {
62360
+ const applied = results.filter((r) => r.status === "applied").length;
62361
+ const manual = results.filter((r) => r.status === "manual").length;
62362
+ const errored = results.filter((r) => r.status === "skipped").length;
62363
+ const counts2 = { items: applied, skipped: manual, failures: errored };
62364
+ if (manual === 0 && errored === 0) return { status: OutcomeStatuses.succeeded, counts: counts2 };
62365
+ const reasonCode = manual > 0 ? OutcomeReasonCodes.UNSUPPORTED : OutcomeReasonCodes.UNKNOWN;
62366
+ if (applied > 0) return { status: OutcomeStatuses.partial, reasonCode, counts: counts2 };
62367
+ return { status: errored > 0 ? OutcomeStatuses.failed : OutcomeStatuses.skipped, reasonCode, counts: counts2 };
62368
+ }
62369
+ function schemaDeployOutcome(results) {
62370
+ const deployed = results.filter((r) => r.status === "deployed").length;
62371
+ const stripped = results.filter((r) => r.status === "stripped").length;
62372
+ const missing = results.filter((r) => r.status === "skipped").length;
62373
+ const failed = results.filter((r) => r.status === "failed").length;
62374
+ const counts2 = { items: deployed, skipped: stripped + missing, failures: failed };
62375
+ if (deployed === results.length) return { status: OutcomeStatuses.succeeded, counts: counts2 };
62376
+ const reasonCode = stripped > 0 ? OutcomeReasonCodes.PERMISSION_MISSING : failed > 0 ? OutcomeReasonCodes.UNKNOWN : OutcomeReasonCodes.NOT_FOUND;
62377
+ return { status: deployed > 0 ? OutcomeStatuses.partial : OutcomeStatuses.failed, reasonCode, counts: counts2 };
62378
+ }
61719
62379
  async function wordpressRoutes(app, opts) {
61720
62380
  const allowLoopback = opts.allowLoopbackWebhooks === true;
61721
62381
  function requireStore() {
@@ -61726,20 +62386,22 @@ async function wordpressRoutes(app, opts) {
61726
62386
  function guarded(connection) {
61727
62387
  return { ...connection, fetchImpl: egressFetch };
61728
62388
  }
61729
- async function assertWordpressUrlAllowed(rawUrl, field) {
62389
+ async function assertWordpressUrlAllowed(rawUrl, field, attempt) {
61730
62390
  const check2 = await resolveWebhookTarget(rawUrl, { allowLoopback });
61731
62391
  if (!check2.ok) {
62392
+ attempt?.failed(void 0, webhookTargetRefusalReason(check2));
61732
62393
  throw validationError(`${field} ${check2.message.replace(/^"url" /, "")}`);
61733
62394
  }
61734
62395
  }
61735
- function requireConnection(store, projectName) {
62396
+ function requireConnection(store, projectName, settle) {
61736
62397
  const connection = store.getConnection(projectName);
61737
62398
  if (!connection) {
62399
+ settle?.({ status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.NOT_CONNECTED });
61738
62400
  throw validationError(`No WordPress connection found for project "${projectName}". Run "canonry wordpress connect ${projectName}" first.`);
61739
62401
  }
61740
62402
  return guarded(connection);
61741
62403
  }
61742
- app.post("/projects/:name/wordpress/connect", async (request) => {
62404
+ app.post("/projects/:name/wordpress/connect", connectionRoute(app, () => ({ integration: "wordpress", action: "connect" }), async (request, _reply, attempt) => {
61743
62405
  return withWordpressErrorHandling(async () => {
61744
62406
  const store = requireStore();
61745
62407
  const project = resolveProject(app.db, request.params.name);
@@ -61751,10 +62413,11 @@ async function wordpressRoutes(app, opts) {
61751
62413
  if (defaultEnv === "staging" && !stagingUrl) {
61752
62414
  throw validationError('defaultEnv "staging" requires stagingUrl');
61753
62415
  }
61754
- await assertWordpressUrlAllowed(url, "url");
61755
- if (stagingUrl) await assertWordpressUrlAllowed(stagingUrl, "stagingUrl");
62416
+ await assertWordpressUrlAllowed(url, "url", attempt);
62417
+ if (stagingUrl) await assertWordpressUrlAllowed(stagingUrl, "stagingUrl", attempt);
61756
62418
  const now = (/* @__PURE__ */ new Date()).toISOString();
61757
62419
  const existing = store.getConnection(project.name);
62420
+ if (existing) attempt.update({ action: "reauth" });
61758
62421
  const nextConnection = {
61759
62422
  projectName: project.name,
61760
62423
  url,
@@ -61784,13 +62447,14 @@ async function wordpressRoutes(app, opts) {
61784
62447
  staging,
61785
62448
  adminUrl: getWpStagingAdminUrl(connection.url)
61786
62449
  };
61787
- });
61788
- });
61789
- app.delete("/projects/:name/wordpress/disconnect", async (request, reply) => {
62450
+ }, attempt);
62451
+ }));
62452
+ app.delete("/projects/:name/wordpress/disconnect", connectionRoute(app, () => ({ integration: "wordpress", action: "disconnect" }), async (request, reply, attempt) => {
61790
62453
  const store = requireStore();
61791
62454
  const project = resolveProject(app.db, request.params.name);
61792
62455
  const deleted = store.deleteConnection(project.name);
61793
62456
  if (!deleted) {
62457
+ attempt.failed(void 0, "NOT_CONNECTED");
61794
62458
  throw notFound("WordPress connection", project.name);
61795
62459
  }
61796
62460
  writeAuditLog(app.db, {
@@ -61801,7 +62465,7 @@ async function wordpressRoutes(app, opts) {
61801
62465
  entityId: project.name
61802
62466
  });
61803
62467
  return reply.status(204).send();
61804
- });
62468
+ }));
61805
62469
  app.get("/projects/:name/wordpress/status", async (request) => {
61806
62470
  const project = resolveProject(app.db, request.params.name);
61807
62471
  const stored = opts.wordpressConnectionStore?.getConnection(project.name);
@@ -61853,10 +62517,10 @@ async function wordpressRoutes(app, opts) {
61853
62517
  });
61854
62518
  });
61855
62519
  app.post("/projects/:name/wordpress/pages", async (request) => {
61856
- return withWordpressErrorHandling(async () => {
62520
+ return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "publish" }, async (settle) => {
61857
62521
  const store = requireStore();
61858
62522
  const project = resolveProject(app.db, request.params.name);
61859
- const connection = requireConnection(store, project.name);
62523
+ const connection = requireConnection(store, project.name, settle);
61860
62524
  const { title, slug, content, status } = request.body ?? {};
61861
62525
  const env = parseEnvInput(request.body?.env);
61862
62526
  if (!title || !slug || !content) {
@@ -61870,14 +62534,15 @@ async function wordpressRoutes(app, opts) {
61870
62534
  entityType: "wordpress_page",
61871
62535
  entityId: created.slug
61872
62536
  });
62537
+ settle({ status: OutcomeStatuses.succeeded, counts: { items: 1 } });
61873
62538
  return created;
61874
- });
62539
+ }, wordpressFailureReason));
61875
62540
  });
61876
62541
  app.put("/projects/:name/wordpress/page", async (request) => {
61877
- return withWordpressErrorHandling(async () => {
62542
+ return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "publish" }, async (settle) => {
61878
62543
  const store = requireStore();
61879
62544
  const project = resolveProject(app.db, request.params.name);
61880
- const connection = requireConnection(store, project.name);
62545
+ const connection = requireConnection(store, project.name, settle);
61881
62546
  const currentSlug = request.body?.currentSlug?.trim();
61882
62547
  if (!currentSlug) {
61883
62548
  throw validationError("currentSlug is required");
@@ -61896,14 +62561,15 @@ async function wordpressRoutes(app, opts) {
61896
62561
  entityType: "wordpress_page",
61897
62562
  entityId: currentSlug
61898
62563
  });
62564
+ settle({ status: OutcomeStatuses.succeeded, counts: { items: 1 } });
61899
62565
  return updated;
61900
- });
62566
+ }, wordpressFailureReason));
61901
62567
  });
61902
62568
  app.post("/projects/:name/wordpress/page/meta", async (request) => {
61903
- return withWordpressErrorHandling(async () => {
62569
+ return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "meta_write" }, async (settle) => {
61904
62570
  const store = requireStore();
61905
62571
  const project = resolveProject(app.db, request.params.name);
61906
- const connection = requireConnection(store, project.name);
62572
+ const connection = requireConnection(store, project.name, settle);
61907
62573
  const slug = request.body?.slug?.trim();
61908
62574
  if (!slug) {
61909
62575
  throw validationError("slug is required");
@@ -61921,14 +62587,15 @@ async function wordpressRoutes(app, opts) {
61921
62587
  entityType: "wordpress_page",
61922
62588
  entityId: slug
61923
62589
  });
62590
+ settle({ status: OutcomeStatuses.succeeded, counts: { items: 1 } });
61924
62591
  return updated;
61925
- });
62592
+ }, wordpressFailureReason));
61926
62593
  });
61927
62594
  app.post("/projects/:name/wordpress/pages/meta/bulk", async (request) => {
61928
- return withWordpressErrorHandling(async () => {
62595
+ return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "meta_write" }, async (settle) => {
61929
62596
  const store = requireStore();
61930
62597
  const project = resolveProject(app.db, request.params.name);
61931
- const connection = requireConnection(store, project.name);
62598
+ const connection = requireConnection(store, project.name, settle);
61932
62599
  const entries = request.body?.entries;
61933
62600
  if (!Array.isArray(entries) || entries.length === 0) {
61934
62601
  throw validationError("entries array is required and must not be empty");
@@ -61950,8 +62617,9 @@ async function wordpressRoutes(app, opts) {
61950
62617
  entityId: `bulk(${applied.map((r) => r.slug).join(",")})`
61951
62618
  });
61952
62619
  }
62620
+ settle(metaWriteOutcome(result.results));
61953
62621
  return result;
61954
- });
62622
+ }, wordpressFailureReason));
61955
62623
  });
61956
62624
  app.get("/projects/:name/wordpress/schema", async (request) => {
61957
62625
  return withWordpressErrorHandling(async () => {
@@ -61967,31 +62635,35 @@ async function wordpressRoutes(app, opts) {
61967
62635
  });
61968
62636
  });
61969
62637
  app.post("/projects/:name/wordpress/schema/manual", async (request) => {
61970
- return withWordpressErrorHandling(async () => {
62638
+ return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "schema_deploy" }, async (settle) => {
61971
62639
  const store = requireStore();
61972
62640
  const project = resolveProject(app.db, request.params.name);
61973
- const connection = requireConnection(store, project.name);
62641
+ const connection = requireConnection(store, project.name, settle);
61974
62642
  const slug = request.body?.slug?.trim();
61975
62643
  const json = request.body?.json;
61976
62644
  if (!slug || !json) {
61977
62645
  throw validationError("slug and json are required");
61978
62646
  }
61979
62647
  const env = parseEnvInput(request.body?.env);
61980
- return buildManualSchemaUpdate(connection, slug, { type: request.body?.type, json }, env);
61981
- });
62648
+ const manual = await buildManualSchemaUpdate(connection, slug, { type: request.body?.type, json }, env);
62649
+ settle(MANUAL_ASSIST_OUTCOME);
62650
+ return manual;
62651
+ }, wordpressFailureReason));
61982
62652
  });
61983
62653
  app.post("/projects/:name/wordpress/schema/deploy", async (request) => {
61984
- return withWordpressErrorHandling(async () => {
62654
+ return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "schema_deploy" }, async (settle) => {
61985
62655
  const store = requireStore();
61986
62656
  const project = resolveProject(app.db, request.params.name);
61987
- const connection = requireConnection(store, project.name);
62657
+ const connection = requireConnection(store, project.name, settle);
61988
62658
  const profile = request.body?.profile;
61989
62659
  if (!profile?.business?.name || !profile?.pages || Object.keys(profile.pages).length === 0) {
61990
62660
  throw validationError("profile with business.name and non-empty pages is required");
61991
62661
  }
61992
62662
  const env = parseEnvInput(request.body?.env);
61993
- return deploySchemaFromProfile(connection, profile, env);
61994
- });
62663
+ const deployed = await deploySchemaFromProfile(connection, profile, env);
62664
+ settle(schemaDeployOutcome(deployed.results));
62665
+ return deployed;
62666
+ }, wordpressFailureReason));
61995
62667
  });
61996
62668
  app.get("/projects/:name/wordpress/schema/status", async (request) => {
61997
62669
  return withWordpressErrorHandling(async () => {
@@ -62012,17 +62684,19 @@ async function wordpressRoutes(app, opts) {
62012
62684
  });
62013
62685
  });
62014
62686
  app.post("/projects/:name/wordpress/llms-txt/manual", async (request) => {
62015
- return withWordpressErrorHandling(async () => {
62687
+ return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "llms_txt" }, async (settle) => {
62016
62688
  const store = requireStore();
62017
62689
  const project = resolveProject(app.db, request.params.name);
62018
- const connection = requireConnection(store, project.name);
62690
+ const connection = requireConnection(store, project.name, settle);
62019
62691
  const content = request.body?.content;
62020
62692
  if (!content) {
62021
62693
  throw validationError("content is required");
62022
62694
  }
62023
62695
  const env = parseEnvInput(request.body?.env);
62024
- return buildManualLlmsTxtUpdate(connection, content, env);
62025
- });
62696
+ const manual = await buildManualLlmsTxtUpdate(connection, content, env);
62697
+ settle(MANUAL_ASSIST_OUTCOME);
62698
+ return manual;
62699
+ }, wordpressFailureReason));
62026
62700
  });
62027
62701
  app.get("/projects/:name/wordpress/audit", async (request) => {
62028
62702
  return withWordpressErrorHandling(async () => {
@@ -62060,17 +62734,20 @@ async function wordpressRoutes(app, opts) {
62060
62734
  });
62061
62735
  });
62062
62736
  app.post("/projects/:name/wordpress/staging/push", async (request) => {
62063
- return withWordpressErrorHandling(async () => {
62737
+ return withWordpressErrorHandling(async () => withFeatureOutcome(app, { feature: "wordpress", operation: "publish" }, async (settle) => {
62064
62738
  const store = requireStore();
62065
62739
  const project = resolveProject(app.db, request.params.name);
62066
- const connection = requireConnection(store, project.name);
62740
+ const connection = requireConnection(store, project.name, settle);
62067
62741
  if (!connection.stagingUrl) {
62068
62742
  throw validationError("No staging URL configured for this project. Reconnect with --staging-url before using staging push.");
62069
62743
  }
62070
- return buildManualStagingPush(connection);
62071
- });
62744
+ const manual = await buildManualStagingPush(connection);
62745
+ settle(MANUAL_ASSIST_OUTCOME);
62746
+ return manual;
62747
+ }, wordpressFailureReason));
62072
62748
  });
62073
62749
  app.post("/projects/:name/wordpress/onboard", async (request) => {
62750
+ const attempt = startConnectionAttempt(app, { integration: "wordpress", action: "connect" });
62074
62751
  return withWordpressErrorHandling(async () => {
62075
62752
  const store = requireStore();
62076
62753
  const project = resolveProject(app.db, request.params.name);
@@ -62082,14 +62759,15 @@ async function wordpressRoutes(app, opts) {
62082
62759
  if (defaultEnv === "staging" && !stagingUrl) {
62083
62760
  throw validationError('defaultEnv "staging" requires stagingUrl');
62084
62761
  }
62085
- await assertWordpressUrlAllowed(url, "url");
62086
- if (stagingUrl) await assertWordpressUrlAllowed(stagingUrl, "stagingUrl");
62762
+ await assertWordpressUrlAllowed(url, "url", attempt);
62763
+ if (stagingUrl) await assertWordpressUrlAllowed(stagingUrl, "stagingUrl", attempt);
62087
62764
  const steps = [];
62088
62765
  let connection = null;
62089
62766
  let pageUrls = [];
62090
62767
  try {
62091
62768
  const now = (/* @__PURE__ */ new Date()).toISOString();
62092
62769
  const existing = store.getConnection(project.name);
62770
+ if (existing) attempt.update({ action: "reauth" });
62093
62771
  const nextConnection = {
62094
62772
  projectName: project.name,
62095
62773
  url,
@@ -62110,7 +62788,9 @@ async function wordpressRoutes(app, opts) {
62110
62788
  entityId: project.name
62111
62789
  });
62112
62790
  steps.push({ name: "connect", status: "completed", summary: `Connected to ${url}` });
62791
+ attempt.succeeded();
62113
62792
  } catch (err) {
62793
+ attempt.failed(err, wordpressFailureReason(err));
62114
62794
  const msg = describeError(err);
62115
62795
  steps.push({ name: "connect", status: "failed", error: msg });
62116
62796
  return { projectName: project.name, steps };
@@ -62131,6 +62811,11 @@ async function wordpressRoutes(app, opts) {
62131
62811
  steps.push({ name: "audit", status: "failed", error: msg });
62132
62812
  return { projectName: project.name, steps };
62133
62813
  }
62814
+ const metaStartedAt = Date.now();
62815
+ const reportMetaStep = (outcome2) => reportFeatureOutcome(app, { feature: "wordpress", operation: "meta_write" }, {
62816
+ ...outcome2,
62817
+ durationBucket: bucketDuration(Date.now() - metaStartedAt)
62818
+ });
62134
62819
  try {
62135
62820
  const metaEntries = [];
62136
62821
  for (const issue of auditIssues) {
@@ -62155,8 +62840,10 @@ async function wordpressRoutes(app, opts) {
62155
62840
  }
62156
62841
  if (metaEntries.length === 0) {
62157
62842
  steps.push({ name: "set-meta", status: "skipped", summary: "No pages with missing meta found" });
62843
+ reportMetaStep({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.NO_DATA });
62158
62844
  } else {
62159
62845
  const result = await bulkSetSeoMeta(connection, metaEntries);
62846
+ reportMetaStep(metaWriteOutcome(result.results));
62160
62847
  const applied = result.results.filter((r) => r.status === "applied").length;
62161
62848
  const manual = result.results.filter((r) => r.status === "manual").length;
62162
62849
  const skipped4 = result.results.filter((r) => r.status === "skipped").length;
@@ -62169,6 +62856,7 @@ async function wordpressRoutes(app, opts) {
62169
62856
  } catch (err) {
62170
62857
  const msg = describeError(err);
62171
62858
  steps.push({ name: "set-meta", status: "failed", error: msg });
62859
+ reportMetaStep(failedOutcome(err, wordpressFailureReason(err)));
62172
62860
  return { projectName: project.name, steps };
62173
62861
  }
62174
62862
  if (skipSchema || !profile) {
@@ -62178,11 +62866,18 @@ async function wordpressRoutes(app, opts) {
62178
62866
  summary: skipSchema ? "Skipped via --skip-schema" : "No --profile provided"
62179
62867
  });
62180
62868
  } else {
62869
+ const schemaStartedAt = Date.now();
62870
+ const reportSchemaStep = (outcome2) => reportFeatureOutcome(app, { feature: "wordpress", operation: "schema_deploy" }, {
62871
+ ...outcome2,
62872
+ durationBucket: bucketDuration(Date.now() - schemaStartedAt)
62873
+ });
62181
62874
  try {
62182
62875
  if (!profile.business?.name || !profile.pages || Object.keys(profile.pages).length === 0) {
62183
62876
  steps.push({ name: "schema-deploy", status: "skipped", summary: "Profile missing business.name or pages" });
62877
+ reportSchemaStep({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.VALIDATION });
62184
62878
  } else {
62185
62879
  const result = await deploySchemaFromProfile(connection, profile);
62880
+ reportSchemaStep(schemaDeployOutcome(result.results));
62186
62881
  const deployed = result.results.filter((r) => r.status === "deployed").length;
62187
62882
  const stripped = result.results.filter((r) => r.status === "stripped").length;
62188
62883
  const skipped4 = result.results.filter((r) => r.status === "skipped").length;
@@ -62195,6 +62890,7 @@ async function wordpressRoutes(app, opts) {
62195
62890
  } catch (err) {
62196
62891
  const msg = describeError(err);
62197
62892
  steps.push({ name: "schema-deploy", status: "failed", error: msg });
62893
+ reportSchemaStep(failedOutcome(err, wordpressFailureReason(err)));
62198
62894
  return { projectName: project.name, steps };
62199
62895
  }
62200
62896
  }
@@ -62262,7 +62958,7 @@ async function wordpressRoutes(app, opts) {
62262
62958
  entityId: project.name
62263
62959
  });
62264
62960
  return { projectName: project.name, steps };
62265
- });
62961
+ }, attempt);
62266
62962
  });
62267
62963
  }
62268
62964
 
@@ -62854,13 +63550,14 @@ async function backlinksRoutes(app, opts) {
62854
63550
  }
62855
63551
  return reply.send(opts.getBacklinksStatus());
62856
63552
  });
62857
- app.post("/backlinks/install", async (_request, reply) => {
63553
+ app.post("/backlinks/install", async (_request, reply) => withFeatureOutcome(app, { feature: "backlinks", operation: "install" }, async (settle) => {
62858
63554
  if (!opts.onInstallBacklinks) {
62859
63555
  throw missingDependency(BACKLINKS_UNSUPPORTED_MESSAGE);
62860
63556
  }
62861
63557
  const result = await opts.onInstallBacklinks();
63558
+ if (result.alreadyPresent) settle({ status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.ALREADY_CONNECTED });
62862
63559
  return reply.status(200).send(result);
62863
- });
63560
+ }, (err) => err instanceof AppError && err.code === "MISSING_DEPENDENCY" ? OutcomeReasonCodes.UNSUPPORTED : void 0));
62864
63561
  app.post("/backlinks/syncs", async (request, reply) => {
62865
63562
  let release = request.body?.release;
62866
63563
  if (!release) {
@@ -79023,6 +79720,7 @@ async function deliverViaDirectPush(env, batch) {
79023
79720
  method: 'POST',
79024
79721
  headers: {
79025
79722
  'content-type': 'application/json',
79723
+ 'user-agent': 'canonry-cloudflare-worker/' + batch.workerVersion,
79026
79724
  'Authorization': 'Bearer ' + bearerToken,
79027
79725
  'X-Canonry-Timestamp': timestamp,
79028
79726
  'X-Canonry-Signature': signature,
@@ -80481,6 +81179,12 @@ async function runBackfillTask(options) {
80481
81179
  pullForBackfill,
80482
81180
  pullErrorPrefix
80483
81181
  } = options;
81182
+ const backfillStartedAt = Date.now();
81183
+ const target = { feature: "server_traffic", operation: "backfill", attribution: currentOutcomeAttribution() };
81184
+ const reportBackfill = (outcome2) => reportFeatureOutcome(app, target, {
81185
+ ...outcome2,
81186
+ durationBucket: bucketDuration(Date.now() - backfillStartedAt)
81187
+ });
80484
81188
  const markFailed = (msg, preserveSourceState = false) => {
80485
81189
  const failedAt = (/* @__PURE__ */ new Date()).toISOString();
80486
81190
  try {
@@ -80496,6 +81200,7 @@ async function runBackfillTask(options) {
80496
81200
  };
80497
81201
  if (sourceRow2.sourceType === TrafficSourceTypes.wordpress) {
80498
81202
  markFailed("Generic WordPress replace backfill is unavailable because retained coverage is unproven. Use a retention-aware repair that declares the unrecoverable span.");
81203
+ reportBackfill({ status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.UNSUPPORTED });
80499
81204
  return;
80500
81205
  }
80501
81206
  let allEvents;
@@ -80506,12 +81211,14 @@ async function runBackfillTask(options) {
80506
81211
  `${pullErrorPrefix}: ${describeError(e)}`,
80507
81212
  e instanceof VercelRetentionClampError
80508
81213
  );
81214
+ reportBackfill(failedOutcome(e, e instanceof VercelRetentionClampError ? OutcomeReasonCodes.NO_DATA : void 0));
80509
81215
  return;
80510
81216
  }
80511
81217
  if (allEvents.length === 0) {
80512
81218
  const finishedAt2 = (/* @__PURE__ */ new Date()).toISOString();
81219
+ let emptyOutcome;
80513
81220
  try {
80514
- app.db.transaction((tx) => {
81221
+ const completed = app.db.transaction((tx) => {
80515
81222
  const latestSource = tx.select().from(trafficSources).where(eq69(trafficSources.id, sourceRow2.id)).get();
80516
81223
  const stillAuthoritative = latestSource && isAuthoritativeTrafficSource(tx, latestSource) && isSameTrafficSourceGeneration(latestSource, sourceRow2);
80517
81224
  if (stillAuthoritative) {
@@ -80522,9 +81229,13 @@ async function runBackfillTask(options) {
80522
81229
  error: "Traffic source was deactivated or reconfigured during backfill",
80523
81230
  finishedAt: finishedAt2
80524
81231
  }).where(eq69(runs.id, runId)).run();
81232
+ return Boolean(stillAuthoritative);
80525
81233
  });
80526
- } catch {
81234
+ emptyOutcome = completed ? { status: OutcomeStatuses.skipped, reasonCode: OutcomeReasonCodes.NO_DATA } : { status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.NOT_CONNECTED };
81235
+ } catch (e) {
81236
+ emptyOutcome = failedOutcome(e);
80527
81237
  }
81238
+ reportBackfill(emptyOutcome);
80528
81239
  return;
80529
81240
  }
80530
81241
  const report = buildTrafficProbeReport(allEvents, { sampleLimit: BACKFILL_SAMPLE_LIMIT });
@@ -80784,11 +81495,30 @@ async function runBackfillTask(options) {
80784
81495
  tx.update(runs).set({ status: RunStatuses.completed, finishedAt }).where(eq69(runs.id, runId)).run();
80785
81496
  return "committed";
80786
81497
  });
81498
+ reportBackfill(commitOutcome === "committed" ? {
81499
+ status: OutcomeStatuses.succeeded,
81500
+ counts: {
81501
+ events: report.totals.normalizedEvents,
81502
+ crawlerHits: report.totals.crawlerHits,
81503
+ aiReferralHits: report.totals.aiReferralHits,
81504
+ aiUserFetchHits: report.totals.aiUserFetchHits
81505
+ }
81506
+ } : {
81507
+ status: OutcomeStatuses.failed,
81508
+ reasonCode: commitOutcome === "source-inactive" ? OutcomeReasonCodes.NOT_CONNECTED : OutcomeReasonCodes.UNSUPPORTED
81509
+ });
80787
81510
  if (commitOutcome === "source-inactive" || commitOutcome === "wordpress-backfill-unsupported") return;
80788
81511
  } catch (e) {
80789
81512
  markFailed(`Backfill rollup write failed: ${describeError(e)}`);
81513
+ reportBackfill(failedOutcome(e));
80790
81514
  }
80791
81515
  }
81516
+ var TRAFFIC_SOURCE_INTEGRATIONS = {
81517
+ [TrafficSourceTypes.cloudflare]: "traffic_cloudflare",
81518
+ [TrafficSourceTypes["cloud-run"]]: "traffic_cloud_run",
81519
+ [TrafficSourceTypes.vercel]: "traffic_vercel",
81520
+ [TrafficSourceTypes.wordpress]: "traffic_wordpress"
81521
+ };
80792
81522
  async function trafficRoutes(app, opts) {
80793
81523
  const pullEvents = opts.pullCloudRunEvents ?? listCloudRunTrafficEvents;
80794
81524
  const resolveAccessToken2 = opts.resolveCloudRunAccessToken ?? defaultResolveAccessToken;
@@ -80820,9 +81550,10 @@ async function trafficRoutes(app, opts) {
80820
81550
  app.addHook("onClose", async () => {
80821
81551
  if (rawSampleRetentionTimer) clearInterval(rawSampleRetentionTimer);
80822
81552
  });
80823
- async function assertWordpressTargetAllowed(baseUrl) {
81553
+ async function assertWordpressTargetAllowed(baseUrl, attempt) {
80824
81554
  const check2 = await resolveWebhookTarget(baseUrl, { allowLoopback });
80825
81555
  if (!check2.ok) {
81556
+ attempt?.failed(void 0, webhookTargetRefusalReason(check2));
80826
81557
  throw validationError(`WordPress baseUrl rejected: ${check2.message}`);
80827
81558
  }
80828
81559
  }
@@ -80877,7 +81608,7 @@ async function trafficRoutes(app, opts) {
80877
81608
  }
80878
81609
  throw validationError(`Traffic source type "${source.sourceType}" cannot be activated`);
80879
81610
  }
80880
- app.post("/projects/:name/traffic/connect/cloud-run", async (request) => {
81611
+ app.post("/projects/:name/traffic/connect/cloud-run", connectionRoute(app, () => ({ integration: "traffic_cloud_run", action: "connect" }), async (request, _reply, attempt) => {
80881
81612
  const project = resolveProject(app.db, request.params.name);
80882
81613
  const body = request.body ?? {};
80883
81614
  const { gcpProjectId, serviceName, location, displayName, keyJson } = body;
@@ -80903,6 +81634,7 @@ async function trafficRoutes(app, opts) {
80903
81634
  }
80904
81635
  const now = (/* @__PURE__ */ new Date()).toISOString();
80905
81636
  const existing = opts.cloudRunCredentialStore.getConnection(project.name);
81637
+ if (existing) attempt.update({ action: "reauth" });
80906
81638
  opts.cloudRunCredentialStore.upsertConnection({
80907
81639
  projectName: project.name,
80908
81640
  gcpProjectId,
@@ -80966,8 +81698,8 @@ async function trafficRoutes(app, opts) {
80966
81698
  opts.onScheduleUpdated?.("upsert", project.id, SchedulableRunKinds["traffic-sync"]);
80967
81699
  }
80968
81700
  return rowToDto(sourceRow2);
80969
- });
80970
- app.post("/projects/:name/traffic/connect/wordpress", async (request) => {
81701
+ }));
81702
+ app.post("/projects/:name/traffic/connect/wordpress", connectionRoute(app, () => ({ integration: "traffic_wordpress", action: "connect" }), async (request, _reply, attempt) => {
80971
81703
  const project = resolveProject(app.db, request.params.name);
80972
81704
  if (!opts.wordpressTrafficCredentialStore) {
80973
81705
  throw validationError("WordPress traffic credential storage is not configured for this deployment");
@@ -80978,7 +81710,7 @@ async function trafficRoutes(app, opts) {
80978
81710
  throw validationError(parsed.error.issues.map((i) => i.message).join("; "));
80979
81711
  }
80980
81712
  const { baseUrl, username, applicationPassword, displayName } = parsed.data;
80981
- await assertWordpressTargetAllowed(baseUrl);
81713
+ await assertWordpressTargetAllowed(baseUrl, attempt);
80982
81714
  try {
80983
81715
  await pullWordpressEvents({
80984
81716
  baseUrl,
@@ -80989,6 +81721,7 @@ async function trafficRoutes(app, opts) {
80989
81721
  fetchImpl: wordpressTrafficFetch
80990
81722
  });
80991
81723
  } catch (e) {
81724
+ attempt.failed(e, e instanceof EgressRefusedError ? "BLOCKED_UNSAFE_URL" : e instanceof EgressFailedError ? "NETWORK" : void 0);
80992
81725
  if (e instanceof WordpressTrafficApiError) {
80993
81726
  throw providerError(
80994
81727
  `WordPress traffic probe failed (HTTP ${e.status}): ${e.message}${e.body ? ` \u2014 ${e.body}` : ""}`
@@ -81000,6 +81733,7 @@ async function trafficRoutes(app, opts) {
81000
81733
  }
81001
81734
  const now = (/* @__PURE__ */ new Date()).toISOString();
81002
81735
  const existing = credentialStore.getConnection(project.name);
81736
+ if (existing) attempt.update({ action: "reauth" });
81003
81737
  credentialStore.upsertConnection({
81004
81738
  projectName: project.name,
81005
81739
  baseUrl,
@@ -81063,8 +81797,8 @@ async function trafficRoutes(app, opts) {
81063
81797
  opts.onScheduleUpdated?.("upsert", project.id, SchedulableRunKinds["traffic-sync"]);
81064
81798
  }
81065
81799
  return rowToDto(sourceRow2);
81066
- });
81067
- app.post("/projects/:name/traffic/connect/vercel", async (request) => {
81800
+ }));
81801
+ app.post("/projects/:name/traffic/connect/vercel", connectionRoute(app, () => ({ integration: "traffic_vercel", action: "connect" }), async (request, _reply, attempt) => {
81068
81802
  const project = resolveProject(app.db, request.params.name);
81069
81803
  if (!opts.vercelTrafficCredentialStore) {
81070
81804
  throw validationError("Vercel traffic credential storage is not configured for this deployment");
@@ -81088,6 +81822,7 @@ async function trafficRoutes(app, opts) {
81088
81822
  maxPages: 1
81089
81823
  });
81090
81824
  } catch (e) {
81825
+ attempt.failed(e);
81091
81826
  if (e instanceof VercelLogsApiError) {
81092
81827
  throw providerError(
81093
81828
  `Vercel traffic probe failed (HTTP ${e.status}): ${e.message}${e.body ? ` \u2014 ${e.body}` : ""}`
@@ -81098,6 +81833,7 @@ async function trafficRoutes(app, opts) {
81098
81833
  }
81099
81834
  const now = (/* @__PURE__ */ new Date()).toISOString();
81100
81835
  const existing = credentialStore.getConnection(project.name);
81836
+ if (existing) attempt.update({ action: "reauth" });
81101
81837
  credentialStore.upsertConnection({
81102
81838
  projectName: project.name,
81103
81839
  projectId,
@@ -81175,8 +81911,8 @@ async function trafficRoutes(app, opts) {
81175
81911
  opts.onScheduleUpdated?.("upsert", project.id, SchedulableRunKinds["traffic-sync"]);
81176
81912
  }
81177
81913
  return rowToDto(sourceRow2);
81178
- });
81179
- app.post("/projects/:name/traffic/connect/cloudflare", async (request) => {
81914
+ }));
81915
+ app.post("/projects/:name/traffic/connect/cloudflare", connectionRoute(app, () => ({ integration: "traffic_cloudflare", action: "connect" }), async (request, _reply, attempt) => {
81180
81916
  const project = resolveProject(app.db, request.params.name);
81181
81917
  if (!opts.cloudflareTrafficCredentialStore) {
81182
81918
  throw validationError("Cloudflare traffic credential storage is not configured for this deployment");
@@ -81192,6 +81928,7 @@ async function trafficRoutes(app, opts) {
81192
81928
  const now2 = (/* @__PURE__ */ new Date()).toISOString();
81193
81929
  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
81930
  const sameModeSource = cloudflareSources.find((row) => parseQueuePullCloudflareSourceConfig(row.configJson));
81931
+ if (sameModeSource) attempt.update({ action: "reauth" });
81195
81932
  const sourceId2 = sameModeSource?.id ?? crypto48.randomUUID();
81196
81933
  const previousCredential2 = credentialStore.getConnectionBySourceId(sourceId2);
81197
81934
  const workerVersion2 = CURRENT_CLOUDFLARE_WORKER_VERSION;
@@ -81327,6 +82064,7 @@ async function trafficRoutes(app, opts) {
81327
82064
  const workerRouteHost = resolveCloudflareWorkerRouteHost(project.canonicalDomain);
81328
82065
  const now = (/* @__PURE__ */ new Date()).toISOString();
81329
82066
  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);
82067
+ if (activeSource) attempt.update({ action: "reauth" });
81330
82068
  const sourceId = activeSource?.id ?? crypto48.randomUUID();
81331
82069
  const parsedActiveConfig = activeSource ? parseDirectPushCloudflareSourceConfig(activeSource.configJson) : null;
81332
82070
  if (activeSource && !parsedActiveConfig) {
@@ -81486,8 +82224,12 @@ async function trafficRoutes(app, opts) {
81486
82224
  workerVersion,
81487
82225
  instructions
81488
82226
  };
81489
- });
81490
- app.post("/projects/:name/traffic/sources/:id/activate", async (request) => {
82227
+ }));
82228
+ app.post("/projects/:name/traffic/sources/:id/activate", connectionRoute(app, (request) => {
82229
+ const source = app.db.select({ sourceType: trafficSources.sourceType }).from(trafficSources).where(eq69(trafficSources.id, request.params.id)).get();
82230
+ const integration = source ? TRAFFIC_SOURCE_INTEGRATIONS[source.sourceType] : void 0;
82231
+ return integration ? { integration, action: "select" } : void 0;
82232
+ }, async (request) => {
81491
82233
  const project = resolveProject(app.db, request.params.name);
81492
82234
  const now = (/* @__PURE__ */ new Date()).toISOString();
81493
82235
  const result = app.db.transaction((tx) => {
@@ -81538,7 +82280,7 @@ async function trafficRoutes(app, opts) {
81538
82280
  opts.onScheduleUpdated?.(result.scheduleAction, project.id, SchedulableRunKinds["traffic-sync"]);
81539
82281
  }
81540
82282
  return rowToDto(result.source);
81541
- });
82283
+ }));
81542
82284
  app.post("/projects/:name/traffic/cloudflare/ingest", {
81543
82285
  bodyLimit: CLOUDFLARE_INGEST_BODY_LIMIT,
81544
82286
  preHandler: async (request, reply) => {
@@ -81567,6 +82309,22 @@ async function trafficRoutes(app, opts) {
81567
82309
  }
81568
82310
  }
81569
82311
  }, async (request, reply) => {
82312
+ const ingestStartedMs = Date.now();
82313
+ const reportIngest = (outcome2) => {
82314
+ try {
82315
+ opts.onTrafficIngested?.("failure" in outcome2 ? {
82316
+ sourceType: TrafficSourceTypes.cloudflare,
82317
+ status: "failed",
82318
+ events: 0,
82319
+ crawlerHits: 0,
82320
+ aiReferralHits: 0,
82321
+ aiUserFetchHits: 0,
82322
+ durationMs: Date.now() - ingestStartedMs,
82323
+ ...routeOutcomeFailure(outcome2.failure, outcome2.reasonCode)
82324
+ } : { sourceType: TrafficSourceTypes.cloudflare, status: "succeeded", ...outcome2, durationMs: Date.now() - ingestStartedMs });
82325
+ } catch {
82326
+ }
82327
+ };
81570
82328
  const authenticated = authenticateCloudflareIngest(
81571
82329
  request,
81572
82330
  opts.cloudflareTrafficCredentialStore
@@ -81584,6 +82342,7 @@ async function trafficRoutes(app, opts) {
81584
82342
  if (sourceRow2.projectId !== project.id) throw authRequired();
81585
82343
  const parsed = cloudflareWorkerIngestRequestSchema.safeParse(request.body);
81586
82344
  if (!parsed.success) {
82345
+ reportIngest({ failure: void 0, reasonCode: "VALIDATION" });
81587
82346
  throw validationError(parsed.error.issues.map((i) => i.message).join("; "));
81588
82347
  }
81589
82348
  const { workerVersion, events } = parsed.data;
@@ -81592,6 +82351,7 @@ async function trafficRoutes(app, opts) {
81592
82351
  (event) => normalizeCloudflareEventHost(event.host) !== canonicalHost
81593
82352
  );
81594
82353
  if (wrongHostIndex !== -1) {
82354
+ reportIngest({ failure: void 0, reasonCode: "VALIDATION" });
81595
82355
  throw validationError(
81596
82356
  `Cloudflare event at index ${wrongHostIndex} does not belong to project domain "${project.canonicalDomain}"`
81597
82357
  );
@@ -81604,25 +82364,37 @@ async function trafficRoutes(app, opts) {
81604
82364
  else normalizationDrops += 1;
81605
82365
  }
81606
82366
  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();
82367
+ let writeResult;
82368
+ try {
82369
+ writeResult = writeTrafficEventBatch({
82370
+ db: app.db,
82371
+ projectId: project.id,
82372
+ sourceId,
82373
+ events: normalized,
82374
+ receivedAt,
82375
+ receiptTtlMs: DIRECT_PUSH_RECEIPT_TTL_MS,
82376
+ sampleLimit,
82377
+ validateSource: (latestRow) => {
82378
+ if (!latestRow || latestRow.projectId !== project.id || latestRow.sourceType !== TrafficSourceTypes.cloudflare || latestRow.status !== TrafficSourceStatuses.connected || !parseDirectPushCloudflareSourceConfig(latestRow.configJson) || !timingSafeEqualHex(bearerHash, latestRow.ingestTokenHash)) {
82379
+ throw authRequired();
82380
+ }
82381
+ },
82382
+ sourceUpdate: {
82383
+ lastWorkerVersion: workerVersion,
82384
+ lastSyncedAt: receivedAt,
82385
+ lastError: null,
82386
+ updatedAt: receivedAt
81618
82387
  }
81619
- },
81620
- sourceUpdate: {
81621
- lastWorkerVersion: workerVersion,
81622
- lastSyncedAt: receivedAt,
81623
- lastError: null,
81624
- updatedAt: receivedAt
81625
- }
82388
+ });
82389
+ } catch (err) {
82390
+ reportIngest({ failure: err });
82391
+ throw err;
82392
+ }
82393
+ reportIngest({
82394
+ events: writeResult.acceptedEvents,
82395
+ crawlerHits: writeResult.crawlerHits,
82396
+ aiReferralHits: writeResult.aiReferralHits,
82397
+ aiUserFetchHits: writeResult.aiUserFetchHits
81626
82398
  });
81627
82399
  return reply.status(200).send({
81628
82400
  acceptedEvents: writeResult.acceptedEvents,
@@ -81860,6 +82632,22 @@ async function trafficRoutes(app, opts) {
81860
82632
  } catch (error) {
81861
82633
  const failedAt = (/* @__PURE__ */ new Date()).toISOString();
81862
82634
  const safeError = error instanceof CloudflareQueueApiError ? error.message : "Cloudflare Queue sync failed; retry the active source.";
82635
+ try {
82636
+ opts.onTrafficSynced?.({
82637
+ status: "failed",
82638
+ sourceType: sourceRow2.sourceType,
82639
+ sourceId: sourceRow2.id,
82640
+ pulledEvents: 0,
82641
+ selfTrafficExcluded: 0,
82642
+ crawlerHits: 0,
82643
+ aiUserFetchHits: 0,
82644
+ aiReferralHits: 0,
82645
+ durationMs: Date.now() - startedMs,
82646
+ // A 401/403 is the stored API token; any other Queue API answer is the pull.
82647
+ errorCode: error instanceof CloudflareQueueApiError ? error.status === 401 || error.status === 403 ? "PROVIDER_AUTH" : "PROVIDER_PULL" : "INTERNAL"
82648
+ });
82649
+ } catch {
82650
+ }
81863
82651
  app.db.transaction((tx) => {
81864
82652
  tx.update(runs).set({ status: RunStatuses.failed, error: safeError, finishedAt: failedAt }).where(eq69(runs.id, runId)).run();
81865
82653
  const latestSource = tx.select().from(trafficSources).where(eq69(trafficSources.id, sourceRow2.id)).get();
@@ -82573,7 +83361,7 @@ async function trafficRoutes(app, opts) {
82573
83361
  }
82574
83362
  }
82575
83363
  });
82576
- app.post("/projects/:name/traffic/sources/:id/backfill", async (request) => {
83364
+ app.post("/projects/:name/traffic/sources/:id/backfill", async (request) => withFeatureOutcome(app, { feature: "server_traffic", operation: "backfill" }, async (recordOutcome) => {
82577
83365
  const project = resolveProject(app.db, request.params.name);
82578
83366
  const sourceRow2 = app.db.select().from(trafficSources).where(eq69(trafficSources.id, request.params.id)).get();
82579
83367
  if (!sourceRow2 || sourceRow2.projectId !== project.id) {
@@ -82714,6 +83502,7 @@ async function trafficRoutes(app, opts) {
82714
83502
  pullErrorPrefix
82715
83503
  }).catch(() => {
82716
83504
  });
83505
+ recordOutcome(null);
82717
83506
  const response = {
82718
83507
  sourceId: sourceRow2.id,
82719
83508
  runId,
@@ -82724,7 +83513,7 @@ async function trafficRoutes(app, opts) {
82724
83513
  daysApplied: appliedDays
82725
83514
  };
82726
83515
  return response;
82727
- });
83516
+ }));
82728
83517
  function buildSourceDetail(projectId, row, since) {
82729
83518
  const crawlerPathRows = app.db.select({
82730
83519
  pathNormalized: crawlerEventsHourly.pathNormalized,
@@ -82790,7 +83579,7 @@ async function trafficRoutes(app, opts) {
82790
83579
  } : null
82791
83580
  };
82792
83581
  }
82793
- app.post("/projects/:name/traffic/sources/:id/reset", async (request) => {
83582
+ app.post("/projects/:name/traffic/sources/:id/reset", async (request) => withFeatureOutcome(app, { feature: "server_traffic", operation: "reset" }, async () => {
82794
83583
  const project = resolveProject(app.db, request.params.name);
82795
83584
  const parsed = trafficResetRequestSchema.safeParse(request.body ?? {});
82796
83585
  if (!parsed.success) {
@@ -82843,7 +83632,7 @@ async function trafficRoutes(app, opts) {
82843
83632
  updatedRow = tx.select().from(trafficSources).where(eq69(trafficSources.id, sourceRow2.id)).get();
82844
83633
  });
82845
83634
  return buildSourceDetail(project.id, updatedRow, new Date(Date.now() - 24 * 60 * 6e4).toISOString());
82846
- });
83635
+ }));
82847
83636
  app.get("/projects/:name/traffic/sources", async (request) => {
82848
83637
  const project = resolveProject(app.db, request.params.name);
82849
83638
  const rows = app.db.select().from(trafficSources).where(eq69(trafficSources.projectId, project.id)).orderBy(desc35(trafficSources.createdAt)).all();
@@ -90894,6 +91683,7 @@ function captureSimpleMeasurementDefinition(db, input) {
90894
91683
  // ../api-routes/src/index.ts
90895
91684
  async function apiRoutes(app, opts) {
90896
91685
  app.decorate("db", opts.db);
91686
+ app.decorate("emitOutcome", createOutcomeEmitter(opts.onOutcome));
90897
91687
  app.setErrorHandler((error, _request, reply) => {
90898
91688
  if (error instanceof AppError) {
90899
91689
  return reply.status(error.statusCode).send(error.toJSON());
@@ -91167,6 +91957,7 @@ async function apiRoutes(app, opts) {
91167
91957
  cloudflareIngestIpRateLimitMax: opts.cloudflareIngestIpRateLimitMax,
91168
91958
  defaultSampleLimit: opts.defaultTrafficSampleLimit,
91169
91959
  onTrafficSynced: opts.onTrafficSynced,
91960
+ onTrafficIngested: opts.onTrafficIngested,
91170
91961
  onScheduleUpdated: opts.onScheduleUpdated,
91171
91962
  allowLoopbackWebhooks: opts.allowLoopbackWebhooks
91172
91963
  });
@@ -92497,6 +93288,7 @@ export {
92497
93288
  bingConnections,
92498
93289
  bingUrlInspections,
92499
93290
  bingKeywordStats,
93291
+ gaConnections,
92500
93292
  gaTrafficSnapshots,
92501
93293
  gaDailyTotals,
92502
93294
  gaAcquisitionDaily,
@@ -92574,6 +93366,7 @@ export {
92574
93366
  createCredentialChecker,
92575
93367
  requireInstanceAdministrator,
92576
93368
  hashApiKey,
93369
+ currentOutcomeAttribution,
92577
93370
  notProbeRun,
92578
93371
  writeAuditLog,
92579
93372
  auditFromRequest,
@@ -92587,9 +93380,15 @@ export {
92587
93380
  SentimentService,
92588
93381
  registerOAuthRoutes,
92589
93382
  registerOAuthAdminRoutes,
93383
+ credentialFailure,
92590
93384
  createRunCompetitorResolver,
92591
93385
  competitorIdentityColumns,
92592
93386
  readMarketCompetitorNames,
93387
+ toAlertView,
93388
+ resolveDestination,
93389
+ webhookOutcomeTarget,
93390
+ webhookTargetRefusalReason,
93391
+ webhookResponseReason,
92593
93392
  latestQueryBasketRevision,
92594
93393
  ensureCurrentQueryBasketRevision,
92595
93394
  queueRunIfProjectIdle,
@@ -92609,19 +93408,20 @@ export {
92609
93408
  deliverWebhook,
92610
93409
  redactNotificationUrl,
92611
93410
  buildOpenApiDocument,
92612
- toAlertView,
92613
- resolveDestination,
92614
93411
  getPlaceDetails,
92615
93412
  hashPlaceDetails,
92616
93413
  getPlaceReviewSignals,
92617
93414
  GSC_REPORTING_TIME_ZONE,
92618
93415
  GSC_DATA_LAG_DAYS,
93416
+ GoogleAuthError,
93417
+ GoogleApiError,
92619
93418
  getAuthUrl,
92620
93419
  exchangeCode,
92621
93420
  refreshAccessToken,
92622
93421
  fetchSearchAnalytics,
92623
93422
  inspectUrl,
92624
93423
  GBP_DAILY_METRICS,
93424
+ GbpApiError,
92625
93425
  listLocations,
92626
93426
  formatStorefrontAddress,
92627
93427
  buildLocationProfileFields,
@@ -92636,6 +93436,7 @@ export {
92636
93436
  hashAttributes,
92637
93437
  listReviews,
92638
93438
  classifyReviewsError,
93439
+ BingApiError,
92639
93440
  getUrlInfo,
92640
93441
  getCrawlIssues,
92641
93442
  EgressRefusedError,