@canonry/canonry 7.62.0 → 7.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/assets/agent-workspace/skills/aero/SKILL.md +7 -0
  2. package/assets/agent-workspace/skills/aero/references/ai-traffic-tracking.md +152 -0
  3. package/assets/agent-workspace/skills/canonry/SKILL.md +1 -0
  4. package/assets/agent-workspace/skills/canonry/references/ai-traffic-tracking.md +145 -0
  5. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +2 -1
  6. package/assets/agent-workspace/skills/canonry/references/server-side-traffic.md +6 -3
  7. package/assets/assets/{AgentSetupPage-DtPnVaeF.js → AgentSetupPage-D6lCJ1G1.js} +1 -1
  8. package/assets/assets/{AuditHistoryPanel-Ce_oy4OV.js → AuditHistoryPanel-DlgI3o-U.js} +1 -1
  9. package/assets/assets/{BacklinksPage-CrqPWRd8.js → BacklinksPage-ByiMqbSI.js} +1 -1
  10. package/assets/assets/{HistoryPage-5v0JM7DN.js → HistoryPage-DclVeuLO.js} +1 -1
  11. package/assets/assets/{MeasurementPropertyPage-Bm5Mrd5a.js → MeasurementPropertyPage-DDHybtHa.js} +1 -1
  12. package/assets/assets/{ProjectPage-DaWUh6iw.js → ProjectPage-vBu6oWFi.js} +1 -1
  13. package/assets/assets/{RunsPage-CfdAR2v2.js → RunsPage-CYBJ6pXX.js} +1 -1
  14. package/assets/assets/{SettingsPage-BFooA7JG.js → SettingsPage-w0GmXir0.js} +1 -1
  15. package/assets/assets/{SiteHealthSection-CerUQgAp.js → SiteHealthSection-DDTjGZ05.js} +2 -2
  16. package/assets/assets/ToneBadge-MKA1xeYc.js +60 -0
  17. package/assets/assets/{TrafficPage-C5fhZGJ2.js → TrafficPage-hUWYv-HT.js} +1 -1
  18. package/assets/assets/{TrafficSourceDetailPage-BoqARHYz.js → TrafficSourceDetailPage-CGHgPxs8.js} +1 -1
  19. package/assets/assets/{account-context-D_CDYrs8.js → account-context-qX-Es0RK.js} +1 -1
  20. package/assets/assets/{card-C6z0XyOo.js → card-CzROANT5.js} +1 -1
  21. package/assets/assets/{extract-error-message-By_wcU8g.js → extract-error-message-BY1STLiX.js} +1 -1
  22. package/assets/assets/{format-helpers-D3Ubm8vk.js → format-helpers-BNQR1HNK.js} +1 -1
  23. package/assets/assets/{index-DSVFZqv6.js → index-LmB57HOs.js} +4 -4
  24. package/assets/assets/{query-client-BETh6SO9.js → query-client-Gz37p16p.js} +1 -1
  25. package/assets/assets/{react-query-DzDr4w41.js → react-query-C6DtBAYQ.js} +1 -1
  26. package/assets/assets/{react-sigma_core.esm.min-3kgtOWbQ.js → react-sigma_core.esm.min-CgxuyPPF.js} +1 -1
  27. package/assets/assets/{service-BUaUaize.js → service-DyHZWnMe.js} +1 -1
  28. package/assets/index.html +7 -7
  29. package/dist/{ads-7WUY5CVC.js → ads-FOUJNHP4.js} +3 -3
  30. package/dist/{agent-NOA33LVM.js → agent-UVDVH7LK.js} +7 -7
  31. package/dist/{agent-ask-PKQDFJ7K.js → agent-ask-UEYN2SQX.js} +3 -3
  32. package/dist/{agent-conversations-7HVWPSFB.js → agent-conversations-F54376Q2.js} +3 -3
  33. package/dist/{agent-memory-Y4M2EWKA.js → agent-memory-SOHBTJ4U.js} +3 -3
  34. package/dist/{agent-providers-P67FWELV.js → agent-providers-MMEK2H6V.js} +3 -3
  35. package/dist/{agent-transcript-C2BYWPRT.js → agent-transcript-5IJ3VAEV.js} +3 -3
  36. package/dist/{analytics-XHYWNNW7.js → analytics-LDWO5IW6.js} +3 -3
  37. package/dist/{apply-6OIWDXQP.js → apply-PSGFDJIC.js} +3 -3
  38. package/dist/{backfill-KQPXCUUJ.js → backfill-HZU3BBOH.js} +9 -9
  39. package/dist/{backlinks-ASYIZFLQ.js → backlinks-X6DXIL2O.js} +3 -3
  40. package/dist/{bing-ZLKJ3MIL.js → bing-3HMFUEOO.js} +3 -3
  41. package/dist/{bootstrap-KMVQY5JD.js → bootstrap-ZUSSLXCI.js} +10 -10
  42. package/dist/{cdp-V2HX4ZLE.js → cdp-FH627HJ6.js} +7 -7
  43. package/dist/{chunk-GSHHYR24.js → chunk-2LAL5W6J.js} +3 -3
  44. package/dist/{chunk-X3GWBK7N.js → chunk-5VAC4KQ2.js} +3 -3
  45. package/dist/{chunk-WKZZILBI.js → chunk-5YC6IU44.js} +2 -2
  46. package/dist/{chunk-5NYS3ABL.js → chunk-67262NJL.js} +2 -2
  47. package/dist/{chunk-2LY4FY4N.js → chunk-A57HYHJ3.js} +1 -1
  48. package/dist/{chunk-KLGHY67V.js → chunk-CWMJQ2JV.js} +4 -4
  49. package/dist/{chunk-YQMQWDTI.js → chunk-DBA374EM.js} +72 -29
  50. package/dist/{chunk-U2WPZZPQ.js → chunk-EHTSCLEL.js} +1 -1
  51. package/dist/{chunk-IVZMXHXE.js → chunk-ERJ54U2I.js} +1 -1
  52. package/dist/{chunk-IALK4DQJ.js → chunk-GRWZC3GQ.js} +1 -1
  53. package/dist/{chunk-YQGVA64Q.js → chunk-H22RCHYH.js} +22 -6
  54. package/dist/{chunk-RGD5SDT3.js → chunk-HNZQVIPS.js} +1 -1
  55. package/dist/{chunk-3NICRFTU.js → chunk-I5QSS3CY.js} +351 -333
  56. package/dist/{chunk-UVDBUMYV.js → chunk-IDQZTPRG.js} +2 -2
  57. package/dist/{chunk-B5CSCGVD.js → chunk-L2QPAUTQ.js} +2 -1
  58. package/dist/{chunk-CJC4AQ2M.js → chunk-LBD7YXVD.js} +2 -2
  59. package/dist/{chunk-SDZKHJ6W.js → chunk-LEG7F3DN.js} +20 -2
  60. package/dist/{chunk-MMRJUOE2.js → chunk-NOQCIXM6.js} +1 -1
  61. package/dist/{chunk-Q7PTBHNG.js → chunk-OIHJJHJR.js} +1 -1
  62. package/dist/{chunk-YBDUZBGU.js → chunk-PH7RTAWW.js} +1 -1
  63. package/dist/{chunk-2HNTNKFC.js → chunk-Q4U2GJME.js} +393 -88
  64. package/dist/{chunk-LAILXR2F.js → chunk-QNFX7ZCT.js} +1 -1
  65. package/dist/{chunk-UUGT7UMF.js → chunk-QPTSZDHE.js} +4 -4
  66. package/dist/{chunk-SYQLUOR7.js → chunk-SG2OPR7J.js} +1 -1
  67. package/dist/{chunk-DAKJBAUN.js → chunk-SN5A767O.js} +1 -1
  68. package/dist/{chunk-YJAN2ZDV.js → chunk-SQYYAYH6.js} +3 -3
  69. package/dist/{chunk-3B2GBWIA.js → chunk-TDWNONBU.js} +5 -5
  70. package/dist/{chunk-U6CNJC2Y.js → chunk-TKLE2Y6H.js} +35 -1
  71. package/dist/{chunk-KTQVRQDM.js → chunk-UBP3AAKA.js} +1 -1
  72. package/dist/{chunk-APQ5EAL2.js → chunk-UYPBR3SF.js} +2 -2
  73. package/dist/{chunk-GMP7OZDW.js → chunk-W5TIRS4U.js} +4 -4
  74. package/dist/{chunk-AO4P75DI.js → chunk-WYTRXYUR.js} +5 -5
  75. package/dist/{chunk-7EWDQUNH.js → chunk-XWDCLX55.js} +8 -8
  76. package/dist/{chunk-OJVXKPIN.js → chunk-ZJTU73GW.js} +3 -3
  77. package/dist/{citations-NOCVQPPU.js → citations-BKWLP5S3.js} +3 -3
  78. package/dist/{cli-catalog-WOWYZ7Q2.js → cli-catalog-VRJS66GR.js} +3 -3
  79. package/dist/{cli-commands-4YSZAPKZ.js → cli-commands-6LZ5OIIP.js} +7 -7
  80. package/dist/cli.js +13 -13
  81. package/dist/{competitor-KQK66W3M.js → competitor-VELMM2TJ.js} +3 -3
  82. package/dist/{content-OHOQO5LP.js → content-I525MITL.js} +3 -3
  83. package/dist/{dashboard-W6MII62Z.js → dashboard-XJJ6NZ2L.js} +6 -6
  84. package/dist/{demo-23H25IOO.js → demo-X7LQ24GS.js} +1 -1
  85. package/dist/{demo-server-WYYWTH4H.js → demo-server-MBPBTJXG.js} +11 -11
  86. package/dist/{discover-557WRGEK.js → discover-E2CV5ZP7.js} +3 -3
  87. package/dist/{discover-eval-GGRZZHHI.js → discover-eval-ESRCWRHP.js} +3 -3
  88. package/dist/{doctor-D5JNJKVE.js → doctor-BQDVERII.js} +3 -3
  89. package/dist/{evidence-DHYYVPWW.js → evidence-NUULK5XD.js} +3 -3
  90. package/dist/{export-cmd-M24S3YBY.js → export-cmd-K324RTN4.js} +3 -3
  91. package/dist/{ga-XM6WXZNP.js → ga-PQ36UFTK.js} +3 -3
  92. package/dist/{gbp-M3S5XYPG.js → gbp-KTYA6KHR.js} +3 -3
  93. package/dist/{google-PULCSUOU.js → google-3D4E7TJX.js} +4 -4
  94. package/dist/{google-marketing-AA625HA4.js → google-marketing-4FRO4PRN.js} +1 -1
  95. package/dist/{health-cmd-LVEW6OBD.js → health-cmd-CZJOZUAQ.js} +3 -3
  96. package/dist/{history-32ZYGZGQ.js → history-JNH34GAM.js} +3 -3
  97. package/dist/index.d.ts +6 -0
  98. package/dist/index.js +24 -24
  99. package/dist/{init-4YT2HMKY.js → init-JRGRHLYU.js} +11 -11
  100. package/dist/{insights-RK3MJ4UM.js → insights-STYJSZBB.js} +3 -3
  101. package/dist/intelligence-service-7LCTJQDL.js +15 -0
  102. package/dist/{journeys-S4CPNZYU.js → journeys-POTSSBOM.js} +3 -3
  103. package/dist/{keys-WCXE7WXV.js → keys-BPCSPX2O.js} +3 -3
  104. package/dist/{keyword-HHVLC4HI.js → keyword-6SML434W.js} +3 -3
  105. package/dist/{logs-VYESWGAN.js → logs-WTKKH4UJ.js} +3 -3
  106. package/dist/{mcp-4LRFIOLC.js → mcp-HTAHMVRC.js} +2 -2
  107. package/dist/mcp.js +9 -9
  108. package/dist/{measurement-plan-AL3EHWRK.js → measurement-plan-LJHKKRZ7.js} +5 -5
  109. package/dist/{notify-LUDG5PWU.js → notify-EARS4LMG.js} +3 -3
  110. package/dist/{organic-evidence-CIOHMKLD.js → organic-evidence-W7ZQQKPL.js} +3 -3
  111. package/dist/{overview-NAZJ6PJG.js → overview-C7KQD4NR.js} +4 -4
  112. package/dist/{project-N3MU5V6T.js → project-YU2FIL4B.js} +3 -3
  113. package/dist/{query-XMI2FV6G.js → query-SYFLWHFA.js} +3 -3
  114. package/dist/{research-MQ6QA5TV.js → research-UOUXMFBV.js} +3 -3
  115. package/dist/{results-export-OFEZXCJK.js → results-export-VYPG2Z6S.js} +3 -3
  116. package/dist/{run-YQS3LCFT.js → run-GZD3MQGS.js} +4 -4
  117. package/dist/{schedule-DF6HCMXN.js → schedule-PAA2NLGF.js} +3 -3
  118. package/dist/{search-MRDJKVOL.js → search-6RWCUEEY.js} +3 -3
  119. package/dist/{serve-5A4K242B.js → serve-FTAFEEJD.js} +25 -25
  120. package/dist/{session-5OXLS7NP.js → session-E4V5MRUE.js} +12 -12
  121. package/dist/{settings-NIF34QPH.js → settings-TTA775EJ.js} +3 -3
  122. package/dist/{skills-JOSNWG7B.js → skills-GA35KKOV.js} +2 -2
  123. package/dist/{snapshot-32DLVRIJ.js → snapshot-QVQVAMMN.js} +3 -3
  124. package/dist/{src-F63SHN5T.js → src-CTHVFVLF.js} +2 -2
  125. package/dist/{status-IERHHP4C.js → status-E7B726S6.js} +4 -4
  126. package/dist/{technical-aeo-D7CA3CK6.js → technical-aeo-SMPN3QDQ.js} +3 -3
  127. package/dist/{telemetry-DRURFCCJ.js → telemetry-CTBFPMWD.js} +4 -4
  128. package/dist/{tools-3IZMSHSY.js → tools-XKGEC56T.js} +11 -11
  129. package/dist/{traffic-AME5NDMN.js → traffic-XACOSYYM.js} +36 -7
  130. package/dist/{users-4CQ6ATAG.js → users-WJC7SGRL.js} +3 -3
  131. package/dist/{visibility-stats-R5JBISBX.js → visibility-stats-H3ZNFV5M.js} +3 -3
  132. package/dist/{wordpress-GFK4GR3O.js → wordpress-72YMWF7F.js} +3 -3
  133. package/package.json +1 -1
  134. package/assets/assets/ToneBadge-DKvZcONW.js +0 -60
  135. package/dist/intelligence-service-UNKFCG5N.js +0 -15
@@ -9,14 +9,14 @@ import {
9
9
  normalizeCloudflareWorkerEvent,
10
10
  sessionizeJourneyRequests,
11
11
  verifyRequestSignature
12
- } from "./chunk-SYQLUOR7.js";
12
+ } from "./chunk-SG2OPR7J.js";
13
13
  import {
14
14
  currentOutcomeAttribution,
15
15
  getRequestContext,
16
16
  registerRequestContext,
17
17
  requestAuditIdentity,
18
18
  sanitizeRequestContext
19
- } from "./chunk-LAILXR2F.js";
19
+ } from "./chunk-QNFX7ZCT.js";
20
20
  import {
21
21
  SentimentIdempotencyConflict,
22
22
  SentimentRepository,
@@ -154,7 +154,7 @@ import {
154
154
  userSessions,
155
155
  users,
156
156
  writeNegativeReviewMaxStars
157
- } from "./chunk-RGD5SDT3.js";
157
+ } from "./chunk-HNZQVIPS.js";
158
158
  import {
159
159
  ADS_ACTIVATE_SCOPE,
160
160
  ADS_ACTIVATION_ABSOLUTE_MAX_ENTITIES,
@@ -258,6 +258,7 @@ import {
258
258
  GoogleMarketingProviders,
259
259
  GtmConnectionStates,
260
260
  GtmSnapshotKinds,
261
+ HEALTH_NO_LONGER_URGENT,
261
262
  InsightActionGroups,
262
263
  JOURNEY_LIST_VISITS_PER_VISITOR,
263
264
  JOURNEY_LOOKBACK_DAYS,
@@ -514,6 +515,7 @@ import {
514
515
  classifySkillFile,
515
516
  classifySurfaceFromCategory,
516
517
  classifyTrafficPath,
518
+ cloudflareQueuePullTrafficSourceConfigSchema,
517
519
  cloudflareTrafficSourceConfigSchema,
518
520
  cloudflareWorkerIngestRequestSchema,
519
521
  cloudflareWorkerIngestResponseSchema,
@@ -618,6 +620,7 @@ import {
618
620
  findDuplicateLocationLabels,
619
621
  forbidden,
620
622
  formatAiReferralClassSummary,
623
+ formatCalendarDay,
621
624
  formatCanonryPluginUpdateHint,
622
625
  formatCanonryPluginVersionMismatch,
623
626
  formatIsoDate,
@@ -739,6 +742,7 @@ import {
739
742
  isRetryableHttpError,
740
743
  isStrictSemver,
741
744
  isSweepProvider,
745
+ isTransientNetworkError,
742
746
  isVertexGroundingRedirect,
743
747
  isVisibilityCompareClassMetric,
744
748
  isoDateDaysBeforeInTimeZone,
@@ -1117,6 +1121,7 @@ import {
1117
1121
  trafficConnectCloudflareResponseSchema,
1118
1122
  trafficConnectVercelRequestSchema,
1119
1123
  trafficConnectWordpressRequestSchema,
1124
+ trafficDeclareGapRequestSchema,
1120
1125
  trafficEventsResponseSchema,
1121
1126
  trafficResetRequestSchema,
1122
1127
  trafficSampleClassifierSchema,
@@ -1158,7 +1163,7 @@ import {
1158
1163
  wordpressSchemaDeployResultDtoSchema,
1159
1164
  wordpressSchemaStatusResultDtoSchema,
1160
1165
  wordpressStatusDtoSchema
1161
- } from "./chunk-U6CNJC2Y.js";
1166
+ } from "./chunk-TKLE2Y6H.js";
1162
1167
 
1163
1168
  // ../api-routes/src/user-session.ts
1164
1169
  import crypto2 from "crypto";
@@ -8381,16 +8386,111 @@ import { z as z2 } from "zod";
8381
8386
 
8382
8387
  // ../api-routes/src/notifications/alert.ts
8383
8388
  import { z } from "zod";
8389
+
8390
+ // ../api-routes/src/notifications/health-copy.ts
8391
+ var HEALTH_CHECK_LABELS = {
8392
+ "db.file.present": "Database missing",
8393
+ "config.file.present": "Config file missing",
8394
+ "canonry.version.current": "Canonry update available",
8395
+ "google.auth.connection": "Search Console sign-in",
8396
+ "google.auth.property-access": "Search Console access",
8397
+ "google.auth.redirect-uri": "Google sign-in setup",
8398
+ "google.auth.scopes": "Search Console permissions",
8399
+ "gbp.auth.connection": "Business Profile sign-in",
8400
+ "gbp.auth.scopes": "Business Profile permissions",
8401
+ "gbp.account.access": "Business Profile access",
8402
+ "gbp.data.recent-sync": "Business Profile sync stalled",
8403
+ "gbp.places.api-key": "Places API key",
8404
+ "gbp.reviews.access": "Review alerts limited",
8405
+ "gbp.reviews.delivery": "Review alert not delivered",
8406
+ "bing.auth.connection": "Bing sign-in",
8407
+ "bing.auth.site-access": "Bing site access",
8408
+ "wordpress.publish.connection": "WordPress publishing",
8409
+ "ga.auth.connection": "GA4 sign-in",
8410
+ "ga.data.recent-data": "GA4 data stopped",
8411
+ "gsc.data.recent-data": "Search Console data stopped",
8412
+ "report.sweeps": "Monthly report sweeps",
8413
+ "report.models": "Monthly report models",
8414
+ "report.daily-data": "Monthly report data gaps",
8415
+ "report.ai-referral-bursts": "AI referral spikes",
8416
+ "ads.auth.connection": "OpenAI ads sign-in",
8417
+ "ads.data.recent-sync": "OpenAI ads sync stalled",
8418
+ "google-ads.auth.connection": "Google Ads sign-in",
8419
+ "google-ads.auth.scopes": "Google Ads permissions",
8420
+ "google-ads.account.context": "Google Ads account",
8421
+ "google-ads.data.recent-snapshot": "Google Ads data stale",
8422
+ "gtm.auth.connection": "Tag Manager sign-in",
8423
+ "gtm.auth.scopes": "Tag Manager permissions",
8424
+ "gtm.container.context": "Tag Manager container",
8425
+ "gtm.data.recent-snapshot": "Tag Manager data stale",
8426
+ "gtm.runtime.firing": "Tags not firing",
8427
+ "google-ads.sync.recent-failures": "Google Ads sync failing",
8428
+ "gtm.sync.recent-failures": "Tag Manager sync failing",
8429
+ "gbp.sync.recent-failures": "Business Profile sync failing",
8430
+ "ga.sync.recent-failures": "GA4 sync failing",
8431
+ "gsc.sync.recent-failures": "Search Console sync failing",
8432
+ "config.providers": "AI provider keys",
8433
+ "config.agent-providers": "Agent provider keys",
8434
+ "traffic.source.connected": "Traffic sync failing",
8435
+ "traffic.source.recent-data": "Traffic data stopped",
8436
+ "traffic.source.sync-lag": "Traffic sync behind",
8437
+ "traffic.source.queue-backlog": "Traffic backlog",
8438
+ "traffic.source.worker-version": "Cloudflare worker out of date",
8439
+ "traffic.source.credentials": "Traffic source sign-in",
8440
+ "traffic.source.scopes": "Traffic source permissions",
8441
+ "traffic.source.cache-blindspot": "Traffic hidden by cache",
8442
+ "backlinks.source.connected": "Backlinks source",
8443
+ "content.winnability.coverage": "Content analysis incomplete",
8444
+ "agent.skills.installed": "Agent skills missing",
8445
+ "agent.skills.current": "Agent skills outdated",
8446
+ "agent.skills.trigger-surface": "Agent skill setup",
8447
+ "gsc.api.reachable": "Can't reach Search Console",
8448
+ "ga.api.reachable": "Can't reach GA4",
8449
+ "gbp.api.reachable": "Can't reach Business Profile",
8450
+ "google-ads.api.reachable": "Can't reach Google Ads",
8451
+ "gtm.api.reachable": "Can't reach Tag Manager",
8452
+ "bing.api.reachable": "Can't reach Bing",
8453
+ "ads.api.reachable": "Can't reach OpenAI ads",
8454
+ "site.reachability": "Website down"
8455
+ };
8456
+ var HEALTH_FIXED_LINES = {
8457
+ "google.auth.refresh-unreachable": "Google is reachable again.",
8458
+ "ga.auth.refresh-unreachable": "Google is reachable again.",
8459
+ "gbp.auth.refresh-unreachable": "Google is reachable again.",
8460
+ "traffic.sync-lag.unrecovered-skip": "The traffic gap is closed.",
8461
+ "traffic.source.all-errored-recently": "Traffic syncs are working again.",
8462
+ "traffic.queue-backlog.remaining": "The traffic backlog is cleared.",
8463
+ "traffic.worker-version.stale": "The Cloudflare worker is up to date.",
8464
+ "gbp.auth.no-connection": "Business Profile is connected."
8465
+ };
8466
+ function firstSentence(text, max2 = 180) {
8467
+ if (!text) return null;
8468
+ const end = text.search(/[.!?](?:\s|$)/u);
8469
+ const sentence = (end === -1 ? text : text.slice(0, end + 1)).trim();
8470
+ if (!sentence) return null;
8471
+ return sentence.length <= max2 ? sentence : `${sentence.slice(0, max2 - 1)}\u2026`;
8472
+ }
8473
+ var HEALTH_NO_SIGNAL_CODE = "health.no-signal";
8474
+ function plainHealthCopy(check, remediation) {
8475
+ if (check.alert) return check.alert;
8476
+ if (check.code === HEALTH_NO_SIGNAL_CODE) {
8477
+ return { label: "Health unknown", line: "No health check produced a result, so health is unknown, not confirmed.", next: null };
8478
+ }
8479
+ return {
8480
+ label: HEALTH_CHECK_LABELS[check.id] ?? "Health check failing",
8481
+ line: firstSentence(check.summary) ?? check.summary,
8482
+ next: firstSentence(remediation)
8483
+ };
8484
+ }
8485
+
8486
+ // ../api-routes/src/notifications/alert.ts
8384
8487
  var alertSeveritySchema = z.enum(["critical", "warning", "success", "info"]);
8385
8488
  var AlertSeverities = alertSeveritySchema.enum;
8386
8489
  var AlertFieldLabels = {
8387
- status: "Status",
8388
- check: "Check",
8389
- previously: "Was",
8390
8490
  remediation: "What to do",
8391
- newlyFailing: "New",
8392
- cleared: "Cleared",
8393
- alsoFailing: "Also failing",
8491
+ next: "Next",
8492
+ alsoOpen: "Also open",
8493
+ fixed: "Fixed",
8394
8494
  event: "Event",
8395
8495
  run: "Run",
8396
8496
  changes: "Changes",
@@ -8401,6 +8501,7 @@ var AlertFieldLabels = {
8401
8501
  replied: "Replied",
8402
8502
  source: "Source"
8403
8503
  };
8504
+ var CANONRY_ALERT_CATEGORY = "\u{1F4C8} Canonry";
8404
8505
  var HEALTH_EVENTS = /* @__PURE__ */ new Set([
8405
8506
  "health.degraded",
8406
8507
  "health.recovered",
@@ -8418,58 +8519,86 @@ function bulletList(lines) {
8418
8519
  return omitted > 0 ? `${body}
8419
8520
  \u2022 \u2026and ${omitted} more` : body;
8420
8521
  }
8522
+ var MAX_ALSO_OPEN = 3;
8523
+ function fixedLines(cleared) {
8524
+ const named = [...new Set(cleared.map((code) => HEALTH_FIXED_LINES[code]).filter((line) => line !== void 0))];
8525
+ const unnamed = cleared.filter((code) => HEALTH_FIXED_LINES[code] === void 0).length;
8526
+ if (unnamed === 0) return named;
8527
+ return [...named, `${unnamed} ${named.length > 0 ? "other " : ""}problem${unnamed === 1 ? "" : "s"} fixed.`];
8528
+ }
8529
+ function labelList(labels) {
8530
+ const counts2 = /* @__PURE__ */ new Map();
8531
+ for (const label of labels) counts2.set(label, (counts2.get(label) ?? 0) + 1);
8532
+ const entries = [...counts2].map(([label, count10]) => count10 > 1 ? `${label} (${count10})` : label);
8533
+ const shown = entries.slice(0, MAX_ALSO_OPEN);
8534
+ const more = entries.length - shown.length;
8535
+ return [...shown.map((entry) => `\u2022 ${entry}`), ...more > 0 ? [`\u2022 and ${more} more`] : []].join("\n");
8536
+ }
8421
8537
  function healthView(payload) {
8422
8538
  const { health, project } = payload;
8423
8539
  const urgentEvent = payload.event === "health.urgent" || payload.event === "health.urgent-resolved";
8424
8540
  const recovered = payload.event === "health.recovered" || payload.event === "health.urgent-resolved";
8425
- const severity = recovered ? AlertSeverities.success : urgentEvent || health.status === "fail" ? AlertSeverities.critical : AlertSeverities.warning;
8426
- const fields = [
8427
- {
8428
- label: AlertFieldLabels.status,
8429
- value: recovered ? urgentEvent ? "resolved" : "recovered" : urgentEvent ? "urgent" : health.status,
8430
- compact: true
8431
- },
8432
- { label: AlertFieldLabels.check, value: health.code, compact: true }
8433
- ];
8434
- if (health.previousStatus) {
8435
- fields.push({ label: AlertFieldLabels.previously, value: health.previousStatus, compact: true });
8436
- }
8437
- if (health.remediation) {
8438
- fields.push({ label: AlertFieldLabels.remediation, value: health.remediation });
8541
+ const website = health.code.startsWith("site.reachability.");
8542
+ const base = {
8543
+ category: CANONRY_ALERT_CATEGORY,
8544
+ url: payload.dashboardUrl,
8545
+ footer: project.canonicalDomain,
8546
+ timestamp: health.checkedAt
8547
+ };
8548
+ if (recovered) {
8549
+ const fixed2 = fixedLines(health.cleared ?? []);
8550
+ if (website) {
8551
+ return { ...base, severity: AlertSeverities.success, title: `${project.name}: Website is back up`, body: "The website answers again.", fields: [] };
8552
+ }
8553
+ if (urgentEvent) {
8554
+ const eased = health.summary.startsWith(HEALTH_NO_LONGER_URGENT);
8555
+ const several = (health.cleared?.length ?? 0) > 1;
8556
+ return {
8557
+ ...base,
8558
+ severity: AlertSeverities.success,
8559
+ title: `${project.name}: ${eased || several ? "No longer urgent" : "Urgent problem fixed"}`,
8560
+ body: firstSentence(eased ? health.summary.slice(HEALTH_NO_LONGER_URGENT.length) : health.summary) ?? "The urgent problem is resolved.",
8561
+ fields: []
8562
+ };
8563
+ }
8564
+ return {
8565
+ ...base,
8566
+ severity: AlertSeverities.success,
8567
+ title: `${project.name}: Back to normal`,
8568
+ body: fixed2.length > 0 ? fixed2.join(" ") : "Every check passes again.",
8569
+ fields: []
8570
+ };
8439
8571
  }
8572
+ const failing = health.failing;
8440
8573
  const added = health.added ?? [];
8441
- if (added.length > 0) {
8442
- const statusOf = /* @__PURE__ */ new Map();
8443
- for (const check of health.failing) if (!statusOf.has(check.code)) statusOf.set(check.code, check.status);
8444
- fields.push({
8445
- label: `${AlertFieldLabels.newlyFailing} (${added.length})`,
8446
- value: bulletList(added.map((code) => `${statusOf.get(code) ?? health.status}: ${code}`))
8447
- });
8448
- }
8449
- const cleared = health.cleared ?? [];
8450
- if (cleared.length > 0) {
8451
- fields.push({
8452
- label: `${AlertFieldLabels.cleared} (${cleared.length})`,
8453
- value: bulletList(cleared)
8454
- });
8455
- }
8456
- const newlyFailing = new Set(added);
8457
- const others = health.failing.filter((check) => check.code !== health.code && !newlyFailing.has(check.code));
8574
+ const newest = added.map((code) => failing.find((check) => check.code === code)).find((check) => check !== void 0);
8575
+ const lead = newest ?? failing.find((check) => check.code === health.code) ?? failing.at(0);
8576
+ const copy = lead ? lead.code === health.code ? plainHealthCopy({ ...lead, summary: health.summary }, lead.remediation ?? health.remediation) : plainHealthCopy(lead, lead.remediation ?? null) : plainHealthCopy({ id: "", status: health.status, code: health.code, summary: health.summary }, health.remediation);
8577
+ const leadStatus = lead?.status ?? health.status;
8578
+ const severity = urgentEvent || leadStatus === "fail" ? AlertSeverities.critical : AlertSeverities.warning;
8579
+ const fields = [];
8580
+ if (copy.next) fields.push({ label: AlertFieldLabels.next, value: copy.next });
8581
+ const others = failing.filter((check) => check !== lead);
8582
+ const otherNew = others.filter((check) => added.includes(check.code));
8583
+ const standing = others.filter((check) => !added.includes(check.code));
8458
8584
  if (others.length > 0) {
8459
8585
  fields.push({
8460
- label: `${AlertFieldLabels.alsoFailing} (${others.length})`,
8461
- value: bulletList(others.map((check) => `${check.status} \u2014 ${check.code}`))
8586
+ label: AlertFieldLabels.alsoOpen,
8587
+ value: labelList([
8588
+ ...otherNew.map((check) => `${plainHealthCopy(check, null).label} (new)`),
8589
+ ...standing.map((check) => plainHealthCopy(check, null).label)
8590
+ ])
8462
8591
  });
8463
8592
  }
8464
- const website = health.code.startsWith("site.reachability.");
8593
+ const fixed = fixedLines(health.cleared ?? []);
8594
+ if (fixed.length > 0) fields.push({ label: AlertFieldLabels.fixed, value: fixed.map((line) => `\u2022 ${line}`).join("\n") });
8595
+ const label = website ? "Website down" : copy.label;
8465
8596
  return {
8597
+ ...base,
8466
8598
  severity,
8467
- title: website ? recovered ? `${project.name} website is back up` : `${project.name} website is down` : urgentEvent ? recovered ? `${project.name}: urgent problem resolved` : `${project.name}: urgent problem` : recovered ? `${project.name} recovered` : `${project.name} \u2014 measurement degraded`,
8468
- body: health.summary,
8469
- fields,
8470
- url: payload.dashboardUrl,
8471
- footer: project.canonicalDomain,
8472
- timestamp: health.checkedAt
8599
+ title: `${project.name}: ${label}${urgentEvent ? " (urgent)" : ""}`,
8600
+ body: copy.line,
8601
+ fields
8473
8602
  };
8474
8603
  }
8475
8604
  function runView(payload) {
@@ -8494,6 +8623,7 @@ function runView(payload) {
8494
8623
  }
8495
8624
  return {
8496
8625
  severity: failed ? AlertSeverities.critical : AlertSeverities.info,
8626
+ category: CANONRY_ALERT_CATEGORY,
8497
8627
  title: payload.project.name,
8498
8628
  fields,
8499
8629
  url: payload.dashboardUrl,
@@ -8533,6 +8663,7 @@ function reviewView(payload) {
8533
8663
  fields.push(sourceField);
8534
8664
  return {
8535
8665
  severity: AlertSeverities.warning,
8666
+ category: CANONRY_ALERT_CATEGORY,
8536
8667
  title: `${project.name}: new ${review.starRating}-star review`,
8537
8668
  body: review.comment ? review.comment.length > 500 ? `${review.comment.slice(0, 499)}\u2026` : review.comment : "Rating only, no text.",
8538
8669
  fields,
@@ -8543,6 +8674,7 @@ function reviewView(payload) {
8543
8674
  }
8544
8675
  return {
8545
8676
  severity: AlertSeverities.warning,
8677
+ category: CANONRY_ALERT_CATEGORY,
8546
8678
  title: `${project.name}: ${reviews.length} new negative reviews`,
8547
8679
  body: bulletList(reviews.map((r) => {
8548
8680
  const text = r.comment ? ` "${snippet(r.comment)}"` : "";
@@ -8564,6 +8696,7 @@ function ratingView(payload) {
8564
8696
  const single = ratings.length === 1 ? ratings[0] : null;
8565
8697
  return {
8566
8698
  severity: AlertSeverities.warning,
8699
+ category: CANONRY_ALERT_CATEGORY,
8567
8700
  title: single ? `${project.name}: Google rating fell to ${single.rating}` : `${project.name}: Google rating fell at ${ratings.length} locations`,
8568
8701
  body: single ? line(single) : bulletList(ratings.map(line)),
8569
8702
  fields: [
@@ -8622,7 +8755,10 @@ function renderDiscord(view) {
8622
8755
  return {
8623
8756
  username: "Canonry",
8624
8757
  embeds: [{
8625
- title: `${SEVERITY_ICONS[view.severity]} ${view.title}`,
8758
+ // The category line names who is speaking; the embed's colour carries
8759
+ // the severity, so the title stays the problem alone.
8760
+ ...view.category ? { author: { name: view.category } } : {},
8761
+ title: view.category ? view.title : `${SEVERITY_ICONS[view.severity]} ${view.title}`,
8626
8762
  ...view.body ? { description: clamp(view.body, DISCORD_MAX_DESCRIPTION) } : {},
8627
8763
  color: DISCORD_COLORS[view.severity],
8628
8764
  fields: view.fields.map((field) => ({
@@ -8652,6 +8788,7 @@ function renderSlack(view) {
8652
8788
  text: headline,
8653
8789
  attachments: [{
8654
8790
  color: SLACK_COLORS[view.severity],
8791
+ ...view.category ? { author_name: view.category } : {},
8655
8792
  title: headline,
8656
8793
  ...absoluteLink(view.url) ? { title_link: absoluteLink(view.url) } : {},
8657
8794
  ...view.body ? { text: clamp(view.body, SLACK_MAX_TEXT) } : {},
@@ -26331,23 +26468,29 @@ async function resolveHostAddresses(hostname, resolveAddresses) {
26331
26468
  }
26332
26469
  if (isLocalhostName(hostname)) return LOCALHOST_ADDRESSES;
26333
26470
  if (resolveAddresses) return await resolveAddresses(hostname);
26334
- try {
26335
- const [ipv4, ipv6] = await Promise.allSettled([dns2.resolve4(hostname), dns2.resolve6(hostname)]);
26336
- const unique3 = /* @__PURE__ */ new Map();
26337
- if (ipv4.status === "fulfilled") {
26338
- for (const address of ipv4.value) {
26339
- unique3.set(`4:${address}`, { address, family: 4 });
26340
- }
26471
+ const first = await queryHostAddresses(hostname);
26472
+ if (first.addresses.length > 0 || !first.transient) return first.addresses;
26473
+ await new Promise((resolve) => setTimeout(resolve, DNS_RETRY_DELAY_MS + Math.random() * DNS_RETRY_DELAY_MS));
26474
+ return (await queryHostAddresses(hostname)).addresses;
26475
+ }
26476
+ var DNS_RETRY_DELAY_MS = 500;
26477
+ async function queryHostAddresses(hostname) {
26478
+ const [ipv4, ipv6] = await Promise.allSettled([dns2.resolve4(hostname), dns2.resolve6(hostname)]);
26479
+ const unique3 = /* @__PURE__ */ new Map();
26480
+ if (ipv4.status === "fulfilled") {
26481
+ for (const address of ipv4.value) {
26482
+ unique3.set(`4:${address}`, { address, family: 4 });
26341
26483
  }
26342
- if (ipv6.status === "fulfilled") {
26343
- for (const address of ipv6.value) {
26344
- unique3.set(`6:${address}`, { address, family: 6 });
26345
- }
26484
+ }
26485
+ if (ipv6.status === "fulfilled") {
26486
+ for (const address of ipv6.value) {
26487
+ unique3.set(`6:${address}`, { address, family: 6 });
26346
26488
  }
26347
- return [...unique3.values()];
26348
- } catch {
26349
- return [];
26350
26489
  }
26490
+ return {
26491
+ addresses: [...unique3.values()],
26492
+ transient: [ipv4, ipv6].some((result) => result.status === "rejected" && isTransientNetworkError(result.reason))
26493
+ };
26351
26494
  }
26352
26495
  function isBlockedAddress(address, options) {
26353
26496
  return blockedAddressReason(address, { allowLoopback: options.allowLoopback }) !== null;
@@ -41190,7 +41333,7 @@ var routeCatalog = [
41190
41333
  method: "post",
41191
41334
  path: "/api/v1/projects/{name}/traffic/sources/{id}/reset",
41192
41335
  summary: "Advance lastSyncedAt to NOW and clear the error state",
41193
- description: "Operator recovery: advances `lastSyncedAt` to NOW, sets `status` back to `connected`, and clears `last_error`. WordPress resets also clear `last_cursor` and the pending-window marker, then record an unrecovered skip through the reset instant so the next drain cannot combine an old cursor with a new window. That prevents replay but is not a historical repair. Generic WordPress replace-mode backfill is unavailable because the plugin feed cannot prove retained coverage; use a retention-aware repair and explicitly declare any unrecoverable span. Common trigger: an idle Vercel/Cloud Run source whose `lastSyncedAt` has aged past upstream retention (`request-logs` ~14d, Cloud Logging 30d) and now throws on every sync. `advanceToNow: true` is required (no implicit reset). Archived sources are rejected with 400 \u2014 re-connect them via the appropriate `traffic/connect/*` endpoint instead.",
41336
+ description: "Operator recovery: advances `lastSyncedAt` to NOW, sets `status` back to `connected`, and clears `last_error`. WordPress resets also clear `last_cursor` and the pending-window marker, then record an unrecovered skip through the reset instant so the next drain cannot combine an old cursor with a new window. That prevents replay but is not a historical repair. Generic WordPress replace-mode backfill is unavailable because the plugin feed cannot prove retained coverage; record the skipped span with `POST /traffic/sources/{id}/declare-gap` once it cannot be recovered. Common trigger: an idle Vercel/Cloud Run source whose `lastSyncedAt` has aged past upstream retention (`request-logs` ~14d, Cloud Logging 30d) and now throws on every sync. `advanceToNow: true` is required (no implicit reset). Archived sources are rejected with 400 \u2014 re-connect them via the appropriate `traffic/connect/*` endpoint instead.",
41194
41337
  tags: ["traffic"],
41195
41338
  parameters: [
41196
41339
  nameParameter,
@@ -41216,6 +41359,36 @@ var routeCatalog = [
41216
41359
  404: errorResponse("Project or traffic source not found.")
41217
41360
  }
41218
41361
  },
41362
+ {
41363
+ method: "post",
41364
+ path: "/api/v1/projects/{name}/traffic/sources/{id}/declare-gap",
41365
+ summary: "Record a source's unrecovered skip as lost and clear it",
41366
+ description: "Records the span a source skipped, through `skippedThroughAt`, as data that cannot be recovered, clears the marker so the `traffic.sync-lag.unrecovered-skip` doctor check passes, and writes a `traffic.source.gap-declared` audit entry with the span. Rollups are untouched. Meant for WordPress, whose plugin feed cannot prove a historical repair, and for any source whose provider no longer retains the span; a recoverable gap should be backfilled instead. `skippedThroughAt` must repeat the current marker exactly, so a newer skip recorded since the operator looked is refused, not cleared.",
41367
+ tags: ["traffic"],
41368
+ parameters: [
41369
+ nameParameter,
41370
+ { name: "id", in: "path", required: true, description: "Traffic source ID.", schema: stringSchema }
41371
+ ],
41372
+ requestBody: {
41373
+ required: true,
41374
+ content: {
41375
+ "application/json": {
41376
+ schema: {
41377
+ type: "object",
41378
+ required: ["skippedThroughAt"],
41379
+ properties: {
41380
+ skippedThroughAt: { type: "string", description: "The source's current `skippedThroughAt`, exactly as read." }
41381
+ }
41382
+ }
41383
+ }
41384
+ }
41385
+ },
41386
+ responses: {
41387
+ 200: jsonResponse("Gap recorded; the source no longer reports an unrecovered skip.", "TrafficSourceDetailDto"),
41388
+ 400: errorResponse("No gap is recorded, or `skippedThroughAt` does not match the current one."),
41389
+ 404: errorResponse("Project or traffic source not found.")
41390
+ }
41391
+ },
41219
41392
  {
41220
41393
  method: "get",
41221
41394
  path: "/api/v1/projects/{name}/traffic/sources",
@@ -43073,7 +43246,17 @@ function post(value, mediaMode = "exact") {
43073
43246
  const mediaSourceUrlsComplete = media.every(
43074
43247
  (item2) => item2.mediaFormat === "PHOTO" && typeof item2.sourceUrl === "string"
43075
43248
  );
43076
- if (!mediaSourceUrlsComplete && mediaMode === "exact")
43249
+ const providerImageUrls = media.map((item2) => {
43250
+ if (item2.mediaFormat !== "PHOTO" || typeof item2.googleUrl !== "string") return null;
43251
+ try {
43252
+ const url = new URL(item2.googleUrl);
43253
+ return url.protocol === "https:" && /^lh\d+\.googleusercontent\.com$/u.test(url.hostname) && !url.username && !url.password && !url.port && !url.hash ? url.href : null;
43254
+ } catch {
43255
+ return null;
43256
+ }
43257
+ });
43258
+ const providerImagesComplete = providerImageUrls.length > 0 && providerImageUrls.every((url) => url !== null);
43259
+ if (!mediaSourceUrlsComplete && !providerImagesComplete && mediaMode === "exact" && raw.state !== GbpLocalPostStates.REJECTED)
43077
43260
  throw new GbpLocalPostResponseError("UNSUPPORTED_MEDIA");
43078
43261
  const parsed = gbpLocalPostDtoSchema.safeParse({
43079
43262
  name: raw.name,
@@ -43084,7 +43267,8 @@ function post(value, mediaMode = "exact") {
43084
43267
  actionType: action?.actionType ?? null,
43085
43268
  articleUrl: action?.url ?? null,
43086
43269
  imageUrls: mediaSourceUrlsComplete ? media.map((item2) => item2.sourceUrl) : [],
43087
- ...mediaMode === "discovery" ? { mediaSourceUrlsComplete } : {},
43270
+ ...mediaMode === "discovery" || !mediaSourceUrlsComplete ? { mediaSourceUrlsComplete } : {},
43271
+ ...providerImagesComplete ? { providerImageUrls } : {},
43088
43272
  createTime: raw.createTime,
43089
43273
  updateTime: raw.updateTime,
43090
43274
  searchUrl: raw.searchUrl ?? null
@@ -43593,11 +43777,16 @@ async function exchangeCode(clientId, clientSecret, code, redirectUri) {
43593
43777
  function escapeRegExp2(str) {
43594
43778
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
43595
43779
  }
43780
+ var TOKEN_REFRESH_RETRY = {
43781
+ maxRetries: 1,
43782
+ isRetryable: isTransientNetworkError,
43783
+ computeDelayMs: (_attempt, _err, jitteredMs) => 500 + jitteredMs
43784
+ };
43596
43785
  async function refreshAccessToken(clientId, clientSecret, currentRefreshToken) {
43597
43786
  validateClientId(clientId);
43598
43787
  validateClientSecret(clientSecret);
43599
43788
  validateRefreshToken(currentRefreshToken);
43600
- const res = await fetch(GOOGLE_TOKEN_URL, {
43789
+ const res = await withRetry(() => fetch(GOOGLE_TOKEN_URL, {
43601
43790
  method: "POST",
43602
43791
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
43603
43792
  body: new URLSearchParams({
@@ -43607,7 +43796,7 @@ async function refreshAccessToken(clientId, clientSecret, currentRefreshToken) {
43607
43796
  grant_type: "refresh_token"
43608
43797
  }),
43609
43798
  signal: AbortSignal.timeout(GOOGLE_REQUEST_TIMEOUT_MS)
43610
- });
43799
+ }), TOKEN_REFRESH_RETRY);
43611
43800
  if (res.status === 429) {
43612
43801
  throw new GoogleAuthError("Google OAuth rate limit exceeded", 429);
43613
43802
  }
@@ -62428,7 +62617,7 @@ async function runBackfillTask(options) {
62428
62617
  }
62429
62618
  };
62430
62619
  if (sourceRow2.sourceType === TrafficSourceTypes.wordpress) {
62431
- markFailed("Generic WordPress replace backfill is unavailable because retained coverage is unproven. Use a retention-aware repair that declares the unrecoverable span.");
62620
+ markFailed(`Generic WordPress replace backfill is unavailable because retained coverage is unproven. Record a span that cannot be recovered with \`canonry traffic declare-gap ${project.name} --source ${sourceRow2.id}\`.`);
62432
62621
  reportBackfill({ status: OutcomeStatuses.failed, reasonCode: OutcomeReasonCodes.UNSUPPORTED });
62433
62622
  return;
62434
62623
  }
@@ -64713,7 +64902,7 @@ async function trafficRoutes(app, opts) {
64713
64902
  }
64714
64903
  if (sourceRow2.sourceType !== TrafficSourceTypes["cloud-run"] && sourceRow2.sourceType !== TrafficSourceTypes.wordpress && sourceRow2.sourceType !== TrafficSourceTypes.vercel) {
64715
64904
  throw validationError(
64716
- `Backfill for source type "${sourceRow2.sourceType}" is not implemented yet \u2014 generic replace backfill supports only cloud-run and vercel; WordPress requires a retention-aware repair.`
64905
+ `Backfill for source type "${sourceRow2.sourceType}" is not implemented yet \u2014 generic replace backfill supports only cloud-run and vercel; a WordPress gap is recorded with \`canonry traffic declare-gap\`.`
64717
64906
  );
64718
64907
  }
64719
64908
  if (!isAuthoritativeTrafficSource(app.db, sourceRow2)) {
@@ -64721,7 +64910,7 @@ async function trafficRoutes(app, opts) {
64721
64910
  }
64722
64911
  if (sourceRow2.sourceType === TrafficSourceTypes.wordpress) {
64723
64912
  throw validationError(
64724
- "Generic WordPress replace backfill is unavailable because retained coverage is unproven. Use a retention-aware repair that declares the unrecoverable span."
64913
+ `Generic WordPress replace backfill is unavailable because retained coverage is unproven. Record a span that cannot be recovered with \`canonry traffic declare-gap ${project.name} --source ${sourceRow2.id}\`.`
64725
64914
  );
64726
64915
  }
64727
64916
  const requestedDays = request.body?.days ?? DEFAULT_BACKFILL_DAYS;
@@ -64979,6 +65168,40 @@ async function trafficRoutes(app, opts) {
64979
65168
  });
64980
65169
  return buildSourceDetail(project.id, updatedRow, new Date(Date.now() - 24 * 60 * 6e4).toISOString());
64981
65170
  }));
65171
+ app.post("/projects/:name/traffic/sources/:id/declare-gap", async (request) => {
65172
+ const project = resolveProject(app.db, request.params.name);
65173
+ const parsed = trafficDeclareGapRequestSchema.safeParse(request.body ?? {});
65174
+ if (!parsed.success) {
65175
+ throw validationError("`skippedThroughAt` must repeat the source's recorded skip.");
65176
+ }
65177
+ let updatedRow;
65178
+ app.db.transaction((tx) => {
65179
+ const sourceRow2 = tx.select().from(trafficSources).where(and69(eq80(trafficSources.projectId, project.id), eq80(trafficSources.id, request.params.id))).get();
65180
+ if (!sourceRow2) {
65181
+ throw notFound("traffic source", request.params.id);
65182
+ }
65183
+ if (!sourceRow2.skippedThroughAt) {
65184
+ throw validationError(`Traffic source "${sourceRow2.id}" has no unrecovered gap to declare.`);
65185
+ }
65186
+ if (sourceRow2.skippedThroughAt !== parsed.data.skippedThroughAt) {
65187
+ throw validationError(
65188
+ `Traffic source "${sourceRow2.id}" now records a gap through ${sourceRow2.skippedThroughAt}, not ${parsed.data.skippedThroughAt}. Look at it again before declaring it.`
65189
+ );
65190
+ }
65191
+ const now = (/* @__PURE__ */ new Date()).toISOString();
65192
+ tx.update(trafficSources).set({ skippedThroughAt: null, updatedAt: now }).where(eq80(trafficSources.id, sourceRow2.id)).run();
65193
+ writeAuditLog(tx, auditFromRequest(request, {
65194
+ projectId: project.id,
65195
+ actor: "api",
65196
+ action: "traffic.source.gap-declared",
65197
+ entityType: "traffic_source",
65198
+ entityId: sourceRow2.id,
65199
+ diff: { skippedThroughAt: sourceRow2.skippedThroughAt, sourceType: sourceRow2.sourceType }
65200
+ }));
65201
+ updatedRow = tx.select().from(trafficSources).where(eq80(trafficSources.id, sourceRow2.id)).get();
65202
+ });
65203
+ return buildSourceDetail(project.id, updatedRow, new Date(Date.now() - 24 * 60 * 6e4).toISOString());
65204
+ });
64982
65205
  app.get("/projects/:name/traffic/sources", async (request) => {
64983
65206
  const project = resolveProject(app.db, request.params.name);
64984
65207
  const rows = app.db.select().from(trafficSources).where(eq80(trafficSources.projectId, project.id)).orderBy(desc39(trafficSources.createdAt)).all();
@@ -66479,7 +66702,12 @@ function unreachable(step, err, details) {
66479
66702
  code: step.code,
66480
66703
  summary: `Could not reach Google to ${step.action}: ${error}`,
66481
66704
  remediation: "Check that this host can resolve and connect to Google (DNS filtering, firewall, or proxy), then re-run. The credential was not tested.",
66482
- details: { ...details, error }
66705
+ details: { ...details, error },
66706
+ alert: {
66707
+ label: "Can't reach Google",
66708
+ line: "The server couldn't reach Google, so GA4 data didn't refresh.",
66709
+ next: "Nothing yet. If it repeats, check the server's internet or DNS."
66710
+ }
66483
66711
  };
66484
66712
  }
66485
66713
  async function checkServiceAccount(conn) {
@@ -67450,7 +67678,12 @@ async function resolveGbpToken(ctx) {
67450
67678
  status: CheckStatuses.fail,
67451
67679
  code: "gbp.auth.no-connection",
67452
67680
  summary: `No Google Business Profile connection for ${ctx.project.canonicalDomain}, but ${ctx.project.name} still has selected locations.`,
67453
- remediation: `Run \`canonry gbp connect ${ctx.project.name}\` to authorize.`
67681
+ remediation: `Run \`canonry gbp connect ${ctx.project.name}\` to authorize.`,
67682
+ alert: {
67683
+ label: "Business Profile not connected",
67684
+ line: `Business Profile isn't connected for ${ctx.project.canonicalDomain}, but locations are still selected.`,
67685
+ next: `Connect it with \`cnry gbp connect ${ctx.project.name}\`, or deselect its locations.`
67686
+ }
67454
67687
  }
67455
67688
  };
67456
67689
  }
@@ -67479,7 +67712,12 @@ async function resolveGbpToken(ctx) {
67479
67712
  code: "gbp.auth.refresh-unreachable",
67480
67713
  summary: `Could not reach Google to refresh the GBP token: ${error}`,
67481
67714
  remediation: "Check that this host can resolve and connect to Google (DNS filtering, firewall, or proxy), then re-run. The stored grant was not tested.",
67482
- details: { domain: conn.domain, error }
67715
+ details: { domain: conn.domain, error },
67716
+ alert: {
67717
+ label: "Can't reach Google",
67718
+ line: "The server couldn't reach Google, so Business Profile data didn't refresh.",
67719
+ next: "Nothing yet. If it repeats, check the server's internet or DNS."
67720
+ }
67483
67721
  }
67484
67722
  };
67485
67723
  }
@@ -68034,7 +68272,12 @@ async function resolveAccessToken(ctx) {
68034
68272
  code: "google.auth.refresh-unreachable",
68035
68273
  summary: `Could not reach Google to refresh the GSC token: ${error}`,
68036
68274
  remediation: "Check that this host can resolve and connect to Google (DNS filtering, firewall, or proxy), then re-run. The stored grant was not tested.",
68037
- details: { domain: conn.domain, error }
68275
+ details: { domain: conn.domain, error },
68276
+ alert: {
68277
+ label: "Can't reach Google",
68278
+ line: "The server couldn't reach Google, so Search Console data didn't refresh.",
68279
+ next: "Nothing yet. If it repeats, check the server's internet or DNS."
68280
+ }
68038
68281
  }
68039
68282
  };
68040
68283
  }
@@ -68565,6 +68808,18 @@ function loadProbes(ctx) {
68565
68808
  configJson: r.configJson
68566
68809
  }));
68567
68810
  }
68811
+ function unrecoveredAlert(projectName, unrecovered) {
68812
+ const through = unrecovered.map((m) => m.skippedThroughAt).sort().at(-1);
68813
+ const day = formatCalendarDay(through.slice(0, 10));
68814
+ const wordpress = unrecovered.filter((m) => m.sourceType === TrafficSourceTypes.wordpress);
68815
+ const next = unrecovered.length === 1 ? wordpress.length === 1 ? `Record it as lost with \`cnry traffic declare-gap ${projectName} --source ${unrecovered[0].id}\`.` : `Backfill it with \`cnry traffic backfill ${projectName} --source ${unrecovered[0].id} --days N\`.` : "Record each WordPress gap with `cnry traffic declare-gap`, and backfill the others.";
68816
+ const names = unrecovered.map((m) => m.displayName).join(", ");
68817
+ return {
68818
+ label: `Traffic gap before ${day}`,
68819
+ line: wordpress.length === unrecovered.length ? `A stretch of traffic before ${day} is missing for ${names}. WordPress can't replay it.` : `A stretch of traffic before ${day} is missing for ${names}.`,
68820
+ next
68821
+ };
68822
+ }
68568
68823
  var sourceConnectedCheck = {
68569
68824
  id: "traffic.source.connected",
68570
68825
  category: CheckCategories.integrations,
@@ -68610,6 +68865,15 @@ var sourceConnectedCheck = {
68610
68865
  erroredIds: errored.map((s) => s.id),
68611
68866
  failuresInARow,
68612
68867
  lastSyncedAt: lastDataAt
68868
+ },
68869
+ alert: recovering ? {
68870
+ label: "Traffic sync failed",
68871
+ line: `The last traffic sync for ${errored.map((s) => s.displayName).join(", ")} failed.`,
68872
+ next: "Nothing yet. The next sync usually recovers."
68873
+ } : {
68874
+ label: "Traffic sync failing",
68875
+ line: `Traffic syncs for ${errored.map((s) => s.displayName).join(", ")} failed ${failuresInARow} times in a row${lastDataAt ? `, with no new data since ${formatCalendarDay(lastDataAt.slice(0, 10))}` : ""}.`,
68876
+ next: `Check the source with \`cnry traffic sources ${ctx.project.name}\`, then reconnect it if needed.`
68613
68877
  }
68614
68878
  };
68615
68879
  }
@@ -68999,7 +69263,7 @@ var syncLagCheck = {
68999
69263
  const unrecoveredWordpress = unrecovered.filter((m) => m.sourceType === "wordpress");
69000
69264
  const unrecoveredOtherSources = unrecovered.filter((m) => m.sourceType !== "wordpress");
69001
69265
  const remediation = [
69002
- unrecoveredWordpress.length > 0 ? "For WordPress, do not run generic replace-mode backfill: the plugin feed cannot prove it still retains every bucket in the gap. Keep existing rollups intact and use a retention-aware repair that explicitly declares the unrecoverable span." : null,
69266
+ unrecoveredWordpress.length > 0 ? "For WordPress, do not run generic replace-mode backfill: the plugin feed cannot prove it still retains every bucket in the gap. Once you accept the span is lost, record it with `canonry traffic declare-gap <project> --source <id>`, which keeps existing rollups and clears this check." : null,
69003
69267
  unrecoveredOtherSources.length > 0 ? "Recover each non-WordPress gap with `canonry traffic backfill <project> --source <id> --days N --wait`, choosing N so the window reaches back past the skipped instant and stays inside the provider's log retention. This clears only when a backfill actually covers the span." : null
69004
69268
  ].filter((part) => part !== null).join(" ");
69005
69269
  return {
@@ -69007,7 +69271,8 @@ var syncLagCheck = {
69007
69271
  code: "traffic.sync-lag.unrecovered-skip",
69008
69272
  summary: `${unrecovered.length} traffic source(s) previously skipped traffic that has not been backfilled: ${unrecovered.map((m) => `${m.displayName} (skipped through ${m.skippedThroughAt})`).join(", ")}. Current lag may look healthy; the gap does not close on its own.`,
69009
69273
  remediation,
69010
- details
69274
+ details,
69275
+ alert: unrecoveredAlert(ctx.project.name, unrecovered)
69011
69276
  };
69012
69277
  }
69013
69278
  const discarding = measured.filter((m) => m.discarding);
@@ -69066,29 +69331,33 @@ var QUEUE_NOT_VISIBLE_STOPS = [
69066
69331
  CloudflareQueueDrainStopReasons["empty-pulls"],
69067
69332
  CloudflareQueueDrainStopReasons["unleased-only"]
69068
69333
  ];
69069
- function queueResidualCause(stopReasons) {
69334
+ function queueResidualCause(stopReasons, projectName, sourceId) {
69070
69335
  const readRuns = "Read why each sync stopped with `canonry runs <project> --kind traffic-sync --format json` (`syncSummary.stopReason`).";
69071
69336
  if (stopReasons.every((reason) => reason !== null && QUEUE_LIMIT_STOPS.includes(reason))) {
69072
69337
  return {
69073
69338
  cause: "The drain stopped at its time limit or pull cap before the Queue was empty.",
69074
- remediation: "If the backlog keeps growing, messages will pass the Queue's retention and be dropped. Shorten the traffic-sync schedule interval, or raise `CANONRY_CLOUDFLARE_QUEUE_SYNC_DEADLINE_MS` (default 4 minutes per sync)."
69339
+ remediation: "If the backlog keeps growing, messages will pass the Queue's retention and be dropped. Shorten the traffic-sync schedule interval, or raise `CANONRY_CLOUDFLARE_QUEUE_SYNC_DEADLINE_MS` (default 4 minutes per sync).",
69340
+ next: "If it keeps growing, sync more often, or the oldest requests expire from the queue."
69075
69341
  };
69076
69342
  }
69077
69343
  if (stopReasons.every((reason) => reason !== null && QUEUE_NOT_VISIBLE_STOPS.includes(reason))) {
69078
69344
  return {
69079
69345
  cause: "Cloudflare returned no more messages to pull while still counting them, so they were leased or not yet visible. The drain stopped before its time limit, so raising the limit does not help.",
69080
- remediation: "Later syncs pull them as they become visible. If the count does not fall across syncs, check the Queue in the Cloudflare dashboard for in-flight or delayed messages before they pass the Queue's retention."
69346
+ remediation: "Later syncs pull them as they become visible. If the count does not fall across syncs, check the Queue in the Cloudflare dashboard for in-flight or delayed messages before they pass the Queue's retention.",
69347
+ next: "Nothing yet. Later syncs pull them once Cloudflare releases them."
69081
69348
  };
69082
69349
  }
69083
69350
  if (stopReasons.every((reason) => reason === CloudflareQueueDrainStopReasons.error)) {
69084
69351
  return {
69085
69352
  cause: "The last sync failed before the Queue was empty.",
69086
- remediation: `Fix the sync error, then run \`canonry traffic sync <project> --source <id>\`. ${readRuns}`
69353
+ remediation: `Fix the sync error, then run \`canonry traffic sync <project> --source <id>\`. ${readRuns}`,
69354
+ next: `Fix the sync error, then run \`cnry traffic sync ${projectName} --source ${sourceId ?? "<id>"}\`.`
69087
69355
  };
69088
69356
  }
69089
69357
  return {
69090
69358
  cause: "The last sync stopped before the Queue was empty.",
69091
- remediation: `${readRuns} If it hit its time limit, shorten the traffic-sync schedule interval or raise \`CANONRY_CLOUDFLARE_QUEUE_SYNC_DEADLINE_MS\` (default 4 minutes per sync).`
69359
+ remediation: `${readRuns} If it hit its time limit, shorten the traffic-sync schedule interval or raise \`CANONRY_CLOUDFLARE_QUEUE_SYNC_DEADLINE_MS\` (default 4 minutes per sync).`,
69360
+ next: `See why the last sync stopped with \`cnry runs ${projectName} --kind traffic-sync\`.`
69092
69361
  };
69093
69362
  }
69094
69363
  var queueBacklogCheck = {
@@ -69131,13 +69400,22 @@ var queueBacklogCheck = {
69131
69400
  const remaining = measured.filter((source) => (source.queueBacklogCount ?? 0) > 0);
69132
69401
  const total = remaining.reduce((sum, source) => sum + source.queueBacklogCount, 0);
69133
69402
  if (total > CLOUDFLARE_QUEUE_RESIDUAL_WARN_COUNT) {
69134
- const { cause, remediation } = queueResidualCause(remaining.map((source) => stopReasons.get(source.id) ?? null));
69403
+ const { cause, remediation, next } = queueResidualCause(
69404
+ remaining.map((source) => stopReasons.get(source.id) ?? null),
69405
+ ctx.project.name,
69406
+ remaining.length === 1 ? remaining[0].id : null
69407
+ );
69135
69408
  return {
69136
69409
  status: CheckStatuses.warn,
69137
69410
  code: "traffic.queue-backlog.remaining",
69138
69411
  summary: `${total.toLocaleString("en-US")} message(s) remained across ${remaining.length} Cloudflare Queue source(s) after the last sync, more than the ${CLOUDFLARE_QUEUE_RESIDUAL_WARN_COUNT.toLocaleString("en-US")} that can arrive while a drain runs. ${cause}`,
69139
69412
  remediation,
69140
- details
69413
+ details,
69414
+ alert: {
69415
+ label: "Traffic backlog",
69416
+ line: `${total.toLocaleString("en-US")} site requests are waiting to be processed. Normal is under ${CLOUDFLARE_QUEUE_RESIDUAL_WARN_COUNT.toLocaleString("en-US")}.`,
69417
+ next
69418
+ }
69141
69419
  };
69142
69420
  }
69143
69421
  if (total > 0) {
@@ -69157,6 +69435,25 @@ var queueBacklogCheck = {
69157
69435
  };
69158
69436
  }
69159
69437
  };
69438
+ function cloudflareReconnectCommand(projectName, source) {
69439
+ const base = `cnry traffic connect cloudflare ${projectName}`;
69440
+ if (!isCloudflareQueuePull(source)) return `\`${base}\``;
69441
+ const parsed = cloudflareQueuePullTrafficSourceConfigSchema.safeParse(source.configJson);
69442
+ if (!parsed.success) {
69443
+ return `\`${base} --delivery-mode queue-pull\` with its Queue options (\`cnry traffic sources ${projectName}\`)`;
69444
+ }
69445
+ const config = parsed.data;
69446
+ const options = [
69447
+ "--delivery-mode queue-pull",
69448
+ `--account-id ${config.accountId}`,
69449
+ `--queue-id ${config.queueId}`,
69450
+ `--queue-name ${config.queueName}`,
69451
+ `--retention-seconds ${config.retentionSeconds}`,
69452
+ ...config.zoneId ? [`--zone-id ${config.zoneId}`] : [],
69453
+ "--api-token-file <path>"
69454
+ ];
69455
+ return `\`${base} ${options.join(" ")}\``;
69456
+ }
69160
69457
  var workerVersionCheck = {
69161
69458
  id: "traffic.source.worker-version",
69162
69459
  category: CheckCategories.integrations,
@@ -69165,6 +69462,7 @@ var workerVersionCheck = {
69165
69462
  run: (ctx) => {
69166
69463
  if (!ctx.project) return skippedNoProject5();
69167
69464
  const sources = loadProbes(ctx).filter((source) => isActiveSource(source) && isCloudflareWorkerSource(source));
69465
+ const probeById = new Map(sources.map((source) => [source.id, source]));
69168
69466
  if (sources.length === 0) {
69169
69467
  return {
69170
69468
  status: CheckStatuses.skipped,
@@ -69193,7 +69491,12 @@ var workerVersionCheck = {
69193
69491
  code: "traffic.worker-version.stale",
69194
69492
  summary: `${stale.length} Cloudflare Worker source(s) most recently reported a version that differs from the generated version.`,
69195
69493
  remediation: "Regenerate and redeploy each source's Cloudflare Worker from the credential-owning host with `canonry traffic connect cloudflare <project>` and the source's existing delivery-mode options; then verify its route or Queue binding.",
69196
- details
69494
+ details,
69495
+ alert: {
69496
+ label: "Cloudflare worker out of date",
69497
+ line: `The Cloudflare traffic worker for ${stale.map((source) => source.displayName).join(", ")} doesn't match this server's version.`,
69498
+ next: stale.length === 1 ? `Redeploy it with ${cloudflareReconnectCommand(ctx.project.name, probeById.get(stale[0].sourceId))}.` : `Redeploy each in its own delivery mode: ${stale.map((source) => `${source.displayName}: ${cloudflareReconnectCommand(ctx.project.name, probeById.get(source.sourceId))}`).join("; ")}.`
69499
+ }
69197
69500
  };
69198
69501
  }
69199
69502
  const waiting = measured.filter((source) => source.state === "waiting-for-first-event");
@@ -69692,6 +69995,7 @@ async function runChecks(ctx, checks, options = {}) {
69692
69995
  summary: output.summary,
69693
69996
  remediation: output.remediation ?? null,
69694
69997
  details: output.details,
69998
+ ...output.alert ? { alert: output.alert } : {},
69695
69999
  durationMs: Date.now() - checkStarted
69696
70000
  });
69697
70001
  }
@@ -69718,8 +70022,9 @@ function supersede(definition2, result, failedCauses) {
69718
70022
  if (threw(definition2, result)) return result;
69719
70023
  const causes = (definition2.supersededBy ?? []).filter((id) => failedCauses.has(id));
69720
70024
  if (causes.length === 0) return result;
70025
+ const { alert: _symptomAlert, ...symptom } = result;
69721
70026
  return {
69722
- ...result,
70027
+ ...symptom,
69723
70028
  status: CheckStatuses.skipped,
69724
70029
  code: `${definition2.id}.superseded`,
69725
70030
  summary: `Superseded by failing ${causes.join(", ")}, which names the cause. ${result.summary}`,