@contember/engine-actions 2.2.0-alpha.3 → 2.2.0-alpha.4

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 (200) hide show
  1. package/CLAUDE.md +58 -0
  2. package/dist/development/index.cjs +19 -7
  3. package/dist/development/index.cjs.map +1 -1
  4. package/dist/development/index.js +19 -7
  5. package/dist/development/index.js.map +1 -1
  6. package/dist/development/src/ActionsExecutionContainerHookFactory.cjs +5 -2
  7. package/dist/development/src/ActionsExecutionContainerHookFactory.cjs.map +1 -1
  8. package/dist/development/src/ActionsExecutionContainerHookFactory.js +5 -2
  9. package/dist/development/src/ActionsExecutionContainerHookFactory.js.map +1 -1
  10. package/dist/development/src/ActionsMetrics.cjs +98 -0
  11. package/dist/development/src/ActionsMetrics.cjs.map +1 -0
  12. package/dist/development/src/ActionsMetrics.js +98 -0
  13. package/dist/development/src/ActionsMetrics.js.map +1 -0
  14. package/dist/development/src/dispatch/EventDispatcher.cjs +27 -9
  15. package/dist/development/src/dispatch/EventDispatcher.cjs.map +1 -1
  16. package/dist/development/src/dispatch/EventDispatcher.js +27 -9
  17. package/dist/development/src/dispatch/EventDispatcher.js.map +1 -1
  18. package/dist/development/src/dispatch/EventsRepository.cjs +49 -20
  19. package/dist/development/src/dispatch/EventsRepository.cjs.map +1 -1
  20. package/dist/development/src/dispatch/EventsRepository.js +49 -20
  21. package/dist/development/src/dispatch/EventsRepository.js.map +1 -1
  22. package/dist/development/src/dispatch/ProjectDispatcher.cjs +21 -13
  23. package/dist/development/src/dispatch/ProjectDispatcher.cjs.map +1 -1
  24. package/dist/development/src/dispatch/ProjectDispatcher.js +21 -13
  25. package/dist/development/src/dispatch/ProjectDispatcher.js.map +1 -1
  26. package/dist/development/src/dispatch/WebhookTargetHandler.cjs +29 -2
  27. package/dist/development/src/dispatch/WebhookTargetHandler.cjs.map +1 -1
  28. package/dist/development/src/dispatch/WebhookTargetHandler.js +29 -2
  29. package/dist/development/src/dispatch/WebhookTargetHandler.js.map +1 -1
  30. package/dist/development/src/graphql/resolvers/ResolversFactory.cjs +1 -1
  31. package/dist/development/src/graphql/resolvers/ResolversFactory.cjs.map +1 -1
  32. package/dist/development/src/graphql/resolvers/ResolversFactory.js +1 -1
  33. package/dist/development/src/graphql/resolvers/ResolversFactory.js.map +1 -1
  34. package/dist/development/src/graphql/resolvers/mutation/SetVariablesMutationResolver.cjs +1 -1
  35. package/dist/development/src/graphql/resolvers/mutation/SetVariablesMutationResolver.cjs.map +1 -1
  36. package/dist/development/src/graphql/resolvers/mutation/SetVariablesMutationResolver.js +1 -1
  37. package/dist/development/src/graphql/resolvers/mutation/SetVariablesMutationResolver.js.map +1 -1
  38. package/dist/development/src/graphql/resolvers/query/EventsQueryResolver.cjs +1 -1
  39. package/dist/development/src/graphql/resolvers/query/EventsQueryResolver.cjs.map +1 -1
  40. package/dist/development/src/graphql/resolvers/query/EventsQueryResolver.js +1 -1
  41. package/dist/development/src/graphql/resolvers/query/EventsQueryResolver.js.map +1 -1
  42. package/dist/development/src/graphql/resolvers/query/VariablesQueryResolver.cjs +1 -4
  43. package/dist/development/src/graphql/resolvers/query/VariablesQueryResolver.cjs.map +1 -1
  44. package/dist/development/src/graphql/resolvers/query/VariablesQueryResolver.js +1 -4
  45. package/dist/development/src/graphql/resolvers/query/VariablesQueryResolver.js.map +1 -1
  46. package/dist/development/src/graphql/schema/actions.graphql.cjs +20 -8
  47. package/dist/development/src/graphql/schema/actions.graphql.cjs.map +1 -1
  48. package/dist/development/src/graphql/schema/actions.graphql.js +20 -8
  49. package/dist/development/src/graphql/schema/actions.graphql.js.map +1 -1
  50. package/dist/development/src/model/VariablesManager.cjs +54 -5
  51. package/dist/development/src/model/VariablesManager.cjs.map +1 -1
  52. package/dist/development/src/model/VariablesManager.js +54 -5
  53. package/dist/development/src/model/VariablesManager.js.map +1 -1
  54. package/dist/development/src/triggers/TriggerPayloadPersister.cjs +3 -1
  55. package/dist/development/src/triggers/TriggerPayloadPersister.cjs.map +1 -1
  56. package/dist/development/src/triggers/TriggerPayloadPersister.js +3 -1
  57. package/dist/development/src/triggers/TriggerPayloadPersister.js.map +1 -1
  58. package/dist/production/index.cjs +19 -7
  59. package/dist/production/index.cjs.map +1 -1
  60. package/dist/production/index.js +19 -7
  61. package/dist/production/index.js.map +1 -1
  62. package/dist/production/src/ActionsExecutionContainerHookFactory.cjs +5 -2
  63. package/dist/production/src/ActionsExecutionContainerHookFactory.cjs.map +1 -1
  64. package/dist/production/src/ActionsExecutionContainerHookFactory.js +5 -2
  65. package/dist/production/src/ActionsExecutionContainerHookFactory.js.map +1 -1
  66. package/dist/production/src/ActionsMetrics.cjs +98 -0
  67. package/dist/production/src/ActionsMetrics.cjs.map +1 -0
  68. package/dist/production/src/ActionsMetrics.js +98 -0
  69. package/dist/production/src/ActionsMetrics.js.map +1 -0
  70. package/dist/production/src/dispatch/EventDispatcher.cjs +27 -9
  71. package/dist/production/src/dispatch/EventDispatcher.cjs.map +1 -1
  72. package/dist/production/src/dispatch/EventDispatcher.js +27 -9
  73. package/dist/production/src/dispatch/EventDispatcher.js.map +1 -1
  74. package/dist/production/src/dispatch/EventsRepository.cjs +49 -20
  75. package/dist/production/src/dispatch/EventsRepository.cjs.map +1 -1
  76. package/dist/production/src/dispatch/EventsRepository.js +49 -20
  77. package/dist/production/src/dispatch/EventsRepository.js.map +1 -1
  78. package/dist/production/src/dispatch/ProjectDispatcher.cjs +21 -13
  79. package/dist/production/src/dispatch/ProjectDispatcher.cjs.map +1 -1
  80. package/dist/production/src/dispatch/ProjectDispatcher.js +21 -13
  81. package/dist/production/src/dispatch/ProjectDispatcher.js.map +1 -1
  82. package/dist/production/src/dispatch/WebhookTargetHandler.cjs +29 -2
  83. package/dist/production/src/dispatch/WebhookTargetHandler.cjs.map +1 -1
  84. package/dist/production/src/dispatch/WebhookTargetHandler.js +29 -2
  85. package/dist/production/src/dispatch/WebhookTargetHandler.js.map +1 -1
  86. package/dist/production/src/graphql/resolvers/ResolversFactory.cjs +1 -1
  87. package/dist/production/src/graphql/resolvers/ResolversFactory.cjs.map +1 -1
  88. package/dist/production/src/graphql/resolvers/ResolversFactory.js +1 -1
  89. package/dist/production/src/graphql/resolvers/ResolversFactory.js.map +1 -1
  90. package/dist/production/src/graphql/resolvers/mutation/SetVariablesMutationResolver.cjs +1 -1
  91. package/dist/production/src/graphql/resolvers/mutation/SetVariablesMutationResolver.cjs.map +1 -1
  92. package/dist/production/src/graphql/resolvers/mutation/SetVariablesMutationResolver.js +1 -1
  93. package/dist/production/src/graphql/resolvers/mutation/SetVariablesMutationResolver.js.map +1 -1
  94. package/dist/production/src/graphql/resolvers/query/EventsQueryResolver.cjs +1 -1
  95. package/dist/production/src/graphql/resolvers/query/EventsQueryResolver.cjs.map +1 -1
  96. package/dist/production/src/graphql/resolvers/query/EventsQueryResolver.js +1 -1
  97. package/dist/production/src/graphql/resolvers/query/EventsQueryResolver.js.map +1 -1
  98. package/dist/production/src/graphql/resolvers/query/VariablesQueryResolver.cjs +1 -4
  99. package/dist/production/src/graphql/resolvers/query/VariablesQueryResolver.cjs.map +1 -1
  100. package/dist/production/src/graphql/resolvers/query/VariablesQueryResolver.js +1 -4
  101. package/dist/production/src/graphql/resolvers/query/VariablesQueryResolver.js.map +1 -1
  102. package/dist/production/src/graphql/schema/actions.graphql.cjs +20 -8
  103. package/dist/production/src/graphql/schema/actions.graphql.cjs.map +1 -1
  104. package/dist/production/src/graphql/schema/actions.graphql.js +20 -8
  105. package/dist/production/src/graphql/schema/actions.graphql.js.map +1 -1
  106. package/dist/production/src/model/VariablesManager.cjs +54 -5
  107. package/dist/production/src/model/VariablesManager.cjs.map +1 -1
  108. package/dist/production/src/model/VariablesManager.js +54 -5
  109. package/dist/production/src/model/VariablesManager.js.map +1 -1
  110. package/dist/production/src/triggers/TriggerPayloadPersister.cjs +3 -1
  111. package/dist/production/src/triggers/TriggerPayloadPersister.cjs.map +1 -1
  112. package/dist/production/src/triggers/TriggerPayloadPersister.js +3 -1
  113. package/dist/production/src/triggers/TriggerPayloadPersister.js.map +1 -1
  114. package/dist/tests/cases/unit/actionsMetrics.test.d.ts +2 -0
  115. package/dist/tests/cases/unit/actionsMetrics.test.d.ts.map +1 -0
  116. package/dist/tests/cases/unit/eventsRepository.test.d.ts +2 -0
  117. package/dist/tests/cases/unit/eventsRepository.test.d.ts.map +1 -0
  118. package/dist/tests/cases/unit/variablesManager.test.d.ts +2 -0
  119. package/dist/tests/cases/unit/variablesManager.test.d.ts.map +1 -0
  120. package/dist/tests/src/event.d.ts +4 -0
  121. package/dist/tests/src/event.d.ts.map +1 -0
  122. package/dist/tests/src/gql.d.ts.map +1 -1
  123. package/dist/tests/src/uuid.d.ts.map +1 -1
  124. package/dist/tests/tsconfig.tsbuildinfo +1 -1
  125. package/dist/types/ActionsExecutionContainerHookFactory.d.ts +3 -1
  126. package/dist/types/ActionsExecutionContainerHookFactory.d.ts.map +1 -1
  127. package/dist/types/ActionsMetrics.d.ts +59 -0
  128. package/dist/types/ActionsMetrics.d.ts.map +1 -0
  129. package/dist/types/ListenerStoreProvider.d.ts.map +1 -1
  130. package/dist/types/authorization/ActionsPermissionsFactory.d.ts.map +1 -1
  131. package/dist/types/dispatch/DispatchWorkerSupervisor.d.ts.map +1 -1
  132. package/dist/types/dispatch/EventDispatcher.d.ts +12 -3
  133. package/dist/types/dispatch/EventDispatcher.d.ts.map +1 -1
  134. package/dist/types/dispatch/EventsRepository.d.ts +14 -2
  135. package/dist/types/dispatch/EventsRepository.d.ts.map +1 -1
  136. package/dist/types/dispatch/LazyDispatchWorker.d.ts.map +1 -1
  137. package/dist/types/dispatch/ProjectDispatcher.d.ts +5 -2
  138. package/dist/types/dispatch/ProjectDispatcher.d.ts.map +1 -1
  139. package/dist/types/dispatch/TargetHandlerResolver.d.ts.map +1 -1
  140. package/dist/types/dispatch/WebhookFetcher.d.ts.map +1 -1
  141. package/dist/types/dispatch/WebhookTargetHandler.d.ts.map +1 -1
  142. package/dist/types/graphql/http/ActionsApiMiddlewareFactory.d.ts.map +1 -1
  143. package/dist/types/graphql/http/ActionsContextResolver.d.ts.map +1 -1
  144. package/dist/types/graphql/http/ActionsGraphQLHandlerFactory.d.ts.map +1 -1
  145. package/dist/types/graphql/http/ActionsWebsocketControllerFactory.d.ts.map +1 -1
  146. package/dist/types/graphql/resolvers/ResolversFactory.d.ts.map +1 -1
  147. package/dist/types/graphql/resolvers/mutation/ProcessBatchMutationResolver.d.ts.map +1 -1
  148. package/dist/types/graphql/resolvers/mutation/RetryEventMutationResolver.d.ts.map +1 -1
  149. package/dist/types/graphql/resolvers/mutation/SetVariablesMutationResolver.d.ts.map +1 -1
  150. package/dist/types/graphql/resolvers/mutation/StopEventMutationResolver.d.ts.map +1 -1
  151. package/dist/types/graphql/resolvers/query/EventsQueryResolver.d.ts.map +1 -1
  152. package/dist/types/graphql/resolvers/query/VariablesQueryResolver.d.ts +1 -4
  153. package/dist/types/graphql/resolvers/query/VariablesQueryResolver.d.ts.map +1 -1
  154. package/dist/types/graphql/schema/actions.graphql.d.ts.map +1 -1
  155. package/dist/types/graphql/schema/index.d.ts +27 -17
  156. package/dist/types/graphql/schema/index.d.ts.map +1 -1
  157. package/dist/types/index.d.ts +6 -0
  158. package/dist/types/index.d.ts.map +1 -1
  159. package/dist/types/model/EventByIdQuery.d.ts.map +1 -1
  160. package/dist/types/model/EventsInProcessingQuery.d.ts.map +1 -1
  161. package/dist/types/model/EventsToProcessQuery.d.ts.map +1 -1
  162. package/dist/types/model/FailedEventsQuery.d.ts.map +1 -1
  163. package/dist/types/model/SetVariableCommand.d.ts.map +1 -1
  164. package/dist/types/model/VariablesManager.d.ts +16 -3
  165. package/dist/types/model/VariablesManager.d.ts.map +1 -1
  166. package/dist/types/model/VariablesQuery.d.ts.map +1 -1
  167. package/dist/types/triggers/TriggerHandler.d.ts.map +1 -1
  168. package/dist/types/triggers/TriggerIndirectChangesFetcher.d.ts.map +1 -1
  169. package/dist/types/triggers/TriggerListenersBuilder.d.ts.map +1 -1
  170. package/dist/types/triggers/TriggerListenersFactory.d.ts.map +1 -1
  171. package/dist/types/triggers/TriggerListenersStore.d.ts.map +1 -1
  172. package/dist/types/triggers/TriggerPayloadBuilder.d.ts.map +1 -1
  173. package/dist/types/triggers/TriggerPayloadManager.d.ts.map +1 -1
  174. package/dist/types/triggers/TriggerPayloadPersister.d.ts +3 -1
  175. package/dist/types/triggers/TriggerPayloadPersister.d.ts.map +1 -1
  176. package/dist/types/tsconfig.tsbuildinfo +1 -1
  177. package/dist/types/utils/map.d.ts.map +1 -1
  178. package/dist/types/utils/notifyChannel.d.ts.map +1 -1
  179. package/graphql.codegen.yml +1 -1
  180. package/package.json +18 -16
  181. package/src/ActionsExecutionContainerHookFactory.ts +6 -0
  182. package/src/ActionsMetrics.ts +135 -0
  183. package/src/dispatch/EventDispatcher.ts +36 -11
  184. package/src/dispatch/EventsRepository.ts +68 -26
  185. package/src/dispatch/ProjectDispatcher.ts +27 -11
  186. package/src/dispatch/WebhookTargetHandler.ts +36 -1
  187. package/src/graphql/resolvers/ResolversFactory.ts +1 -1
  188. package/src/graphql/resolvers/mutation/SetVariablesMutationResolver.ts +1 -1
  189. package/src/graphql/resolvers/query/EventsQueryResolver.ts +1 -1
  190. package/src/graphql/resolvers/query/VariablesQueryResolver.ts +1 -4
  191. package/src/graphql/schema/actions.graphql.ts +20 -8
  192. package/src/graphql/schema/index.ts +30 -17
  193. package/src/index.ts +27 -5
  194. package/src/model/VariablesManager.ts +66 -6
  195. package/src/triggers/TriggerPayloadPersister.ts +6 -0
  196. package/tests/cases/unit/actionsMetrics.test.ts +76 -0
  197. package/tests/cases/unit/eventsRepository.test.ts +136 -0
  198. package/tests/cases/unit/variablesManager.test.ts +154 -0
  199. package/tests/cases/unit/webhookHandler.test.ts +80 -24
  200. package/tests/src/event.ts +27 -0
@@ -1 +1 @@
1
- {"version":3,"file":"map.d.ts","sourceRoot":"","sources":["../../../src/utils/map.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,WAAW,GAAI,CAAC,EAAE,CAAC,EAAE,CAAC,SAAS,CAAC,EAC5C,KAAK,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,EACd,KAAK,CAAC,EACN,cAAc,MAAM,CAAC,KACnB,CAQF,CAAA"}
1
+ {"version":3,"file":"map.d.ts","sourceRoot":"","sources":["../../../src/utils/map.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,WAAW,GAAI,CAAC,EAAE,CAAC,EAAE,CAAC,SAAS,CAAC,OACvC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,OACT,CAAC,gBACQ,MAAM,CAAC,KACnB,CAQF,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"notifyChannel.d.ts","sourceRoot":"","sources":["../../../src/utils/notifyChannel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAE5C,eAAO,MAAM,mBAAmB,kBAAkB,CAAA;AAClD,eAAO,MAAM,MAAM,GAAU,IAAI,MAAM,EAAE,aAAa,MAAM,kBAE3D,CAAA"}
1
+ {"version":3,"file":"notifyChannel.d.ts","sourceRoot":"","sources":["../../../src/utils/notifyChannel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAE5C,eAAO,MAAM,mBAAmB,kBAAkB,CAAA;AAClD,eAAO,MAAM,MAAM,OAAc,MAAM,eAAe,MAAM,kBAE3D,CAAA"}
@@ -10,7 +10,7 @@ generates:
10
10
  enumsAsTypes: "1"
11
11
  scalars:
12
12
  DateTime: Date
13
- Uuid: string
13
+ UUID: string
14
14
  Json: any
15
15
  plugins:
16
16
  - "typescript"
package/package.json CHANGED
@@ -22,24 +22,26 @@
22
22
  }
23
23
  },
24
24
  "dependencies": {
25
- "@contember/authorization": "2.2.0-alpha.3",
26
- "@contember/database": "2.2.0-alpha.3",
27
- "@contember/database-migrations": "2.2.0-alpha.3",
28
- "@contember/engine-common": "2.2.0-alpha.3",
29
- "@contember/engine-content-api": "2.2.0-alpha.3",
30
- "@contember/engine-http": "2.2.0-alpha.3",
31
- "@contember/engine-plugins": "2.2.0-alpha.3",
32
- "@contember/engine-system-api": "2.2.0-alpha.3",
33
- "@contember/engine-tenant-api": "2.2.0-alpha.3",
34
- "@contember/graphql-utils": "2.2.0-alpha.3",
35
- "@contember/logger": "2.2.0-alpha.3",
36
- "@contember/schema": "2.2.0-alpha.3",
37
- "@contember/schema-utils": "2.2.0-alpha.3",
38
- "@contember/typesafe": "2.2.0-alpha.3",
25
+ "@contember/authorization": "2.2.0-alpha.4",
26
+ "@contember/database": "2.2.0-alpha.4",
27
+ "@contember/database-migrations": "2.2.0-alpha.4",
28
+ "@contember/engine-common": "2.2.0-alpha.4",
29
+ "@contember/engine-content-api": "2.2.0-alpha.4",
30
+ "@contember/engine-http": "2.2.0-alpha.4",
31
+ "@contember/engine-plugins": "2.2.0-alpha.4",
32
+ "@contember/engine-system-api": "2.2.0-alpha.4",
33
+ "@contember/engine-tenant-api": "2.2.0-alpha.4",
34
+ "@contember/graphql-utils": "2.2.0-alpha.4",
35
+ "@contember/logger": "2.2.0-alpha.4",
36
+ "@contember/schema": "2.2.0-alpha.4",
37
+ "@contember/schema-utils": "2.2.0-alpha.4",
38
+ "@contember/typesafe": "2.2.0-alpha.4",
39
39
  "@graphql-tools/schema": "^10.0.31",
40
- "graphql-tag": "^2.12.6"
40
+ "graphql-tag": "^2.12.6",
41
+ "prom-client": "^12.0.0"
41
42
  },
42
43
  "devDependencies": {
44
+ "@contember/database-tester": "2.2.0-alpha.4",
43
45
  "@types/bun": "^1.3.10",
44
46
  "@types/ws": "^8.18.1",
45
47
  "graphql": "^16.13.1"
@@ -49,7 +51,7 @@
49
51
  "pg": "^8.20.0"
50
52
  },
51
53
  "type": "module",
52
- "version": "2.2.0-alpha.3",
54
+ "version": "2.2.0-alpha.4",
53
55
  "repository": {
54
56
  "type": "git",
55
57
  "url": "git+https://github.com/contember/contember.git",
@@ -3,10 +3,14 @@ import { ListenerStoreProvider } from './ListenerStoreProvider.js'
3
3
  import { TriggerPayloadBuilder } from './triggers/TriggerPayloadBuilder.js'
4
4
  import { TriggerPayloadPersister } from './triggers/TriggerPayloadPersister.js'
5
5
  import { ExecutionContainerHook } from '@contember/engine-content-api'
6
+ import { ActionsMetrics } from './ActionsMetrics.js'
6
7
 
7
8
  export class ActionsExecutionContainerHookFactory {
8
9
  constructor(
9
10
  private readonly listenerStoreProvider: ListenerStoreProvider,
11
+ // Resolved lazily: the metrics singleton lives in the master container and is wired up when the
12
+ // dispatch worker boots, which precedes any content request that could enqueue an event.
13
+ private readonly metricsProvider: () => ActionsMetrics | undefined,
10
14
  ) {
11
15
  }
12
16
 
@@ -18,6 +22,7 @@ export class ActionsExecutionContainerHookFactory {
18
22
  mapperFactory,
19
23
  { whereBuilder, schema, pathFactory, systemSchema, providers, stage, project, schemaMeta, joinBuilder, userInfo, triggeredActionsCollector },
20
24
  ) => {
25
+ const projectMetrics = this.metricsProvider()?.forProject(project.slug)
21
26
  mapperFactory.hooks.push(mapper => {
22
27
  const triggerPayloadPersister = new TriggerPayloadPersister(
23
28
  mapper,
@@ -29,6 +34,7 @@ export class ActionsExecutionContainerHookFactory {
29
34
  mapper.identityId,
30
35
  userInfo,
31
36
  triggeredActionsCollector,
37
+ projectMetrics,
32
38
  )
33
39
  const triggerPayloadBuilder = new TriggerPayloadBuilder(mapper)
34
40
  const payloadManager = new TriggerPayloadManager(triggerPayloadBuilder, triggerPayloadPersister)
@@ -0,0 +1,135 @@
1
+ import prom, { Registry } from 'prom-client'
2
+
3
+ const LABEL_PROJECT = 'contember_project'
4
+ type Label = typeof LABEL_PROJECT
5
+
6
+ /**
7
+ * Prometheus metrics for the Actions dispatch subsystem.
8
+ *
9
+ * Two audiences, deliberately kept as cheap in-memory counters/gauges (no table polling):
10
+ * - system / operator — is the dispatch loop alive and flowing, or stuck?
11
+ * `workerHeartbeat` (liveness; works even when the queue is empty) + `workerCrashed`.
12
+ * - userland — are webhook deliveries failing?
13
+ * `eventsSucceeded` / `deliveryAttemptsFailed` / `eventsFailed`.
14
+ *
15
+ * Counting units matter: a *delivery attempt* and an *event* are different. A success is always
16
+ * terminal (one event = one successful attempt), but a failure splits — a failed attempt may be
17
+ * retried many times, while a terminally failed event gives up once. Hence the asymmetric set:
18
+ * one success counter, but both a per-attempt failure counter and a terminal-event failure counter.
19
+ *
20
+ * Queue backlog is *estimated* as a trend from the counters:
21
+ * enqueued − succeeded − failed (compare rates; the absolute value drifts across restarts and
22
+ * ignores manual retry/stop, so for an exact depth query the `actions_event` table instead).
23
+ *
24
+ * Labelled only by `contember_project` to keep cardinality bounded; per-target / per-trigger
25
+ * detail belongs in the table-backed userland view, not in Prometheus.
26
+ */
27
+ export class ActionsMetrics {
28
+ /** Events written to the queue (inflow). Unit: event. */
29
+ public readonly eventsEnqueued: prom.Counter<Label>
30
+ /** Events delivered successfully (terminal). Unit: event (= successful attempt). */
31
+ public readonly eventsSucceeded: prom.Counter<Label>
32
+ /** Failed webhook delivery attempts, including ones that will be retried. Unit: attempt. */
33
+ public readonly deliveryAttemptsFailed: prom.Counter<Label>
34
+ /** Events that reached a terminal failed state (retries exhausted or unknown target). Unit: event. */
35
+ public readonly eventsFailed: prom.Counter<Label>
36
+ /** Unix timestamp (seconds) of the last dispatch-loop iteration; staleness ⇒ stuck/dead worker. */
37
+ public readonly workerHeartbeat: prom.Gauge<Label>
38
+ /** Dispatch-loop crashes (each is auto-restarted by the supervisor). */
39
+ public readonly workerCrashed: prom.Counter<Label>
40
+
41
+ constructor(registry: Registry) {
42
+ const labelNames: Label[] = [LABEL_PROJECT]
43
+ this.eventsEnqueued = new prom.Counter({
44
+ registers: [registry],
45
+ name: 'contember_actions_events_enqueued_total',
46
+ help: 'Total number of action events written to the queue (inflow), by contember_project.',
47
+ labelNames,
48
+ })
49
+ this.eventsSucceeded = new prom.Counter({
50
+ registers: [registry],
51
+ name: 'contember_actions_events_succeeded_total',
52
+ help: 'Total number of action events delivered successfully, by contember_project.',
53
+ labelNames,
54
+ })
55
+ this.deliveryAttemptsFailed = new prom.Counter({
56
+ registers: [registry],
57
+ name: 'contember_actions_delivery_attempts_failed_total',
58
+ help: 'Total number of failed webhook delivery attempts (including attempts that will be retried), by contember_project.',
59
+ labelNames,
60
+ })
61
+ this.eventsFailed = new prom.Counter({
62
+ registers: [registry],
63
+ name: 'contember_actions_events_failed_total',
64
+ help: 'Total number of action events that reached a terminal failed state (retries exhausted or unknown target), by contember_project.',
65
+ labelNames,
66
+ })
67
+ this.workerHeartbeat = new prom.Gauge({
68
+ registers: [registry],
69
+ name: 'contember_actions_worker_heartbeat_timestamp_seconds',
70
+ help: 'Unix timestamp of the last dispatch-loop iteration per contember_project; staleness indicates a stuck or dead worker.',
71
+ labelNames,
72
+ })
73
+ this.workerCrashed = new prom.Counter({
74
+ registers: [registry],
75
+ name: 'contember_actions_worker_crashed_total',
76
+ help: 'Total number of dispatch-loop crashes per contember_project (each is auto-restarted by the supervisor).',
77
+ labelNames,
78
+ })
79
+ }
80
+
81
+ public forProject(projectSlug: string): ProjectActionsMetrics {
82
+ return new ProjectActionsMetrics(this, projectSlug)
83
+ }
84
+ }
85
+
86
+ /**
87
+ * A view of {@link ActionsMetrics} bound to a single project, so call sites don't repeat the label.
88
+ */
89
+ export class ProjectActionsMetrics {
90
+ private readonly labels: { [LABEL_PROJECT]: string }
91
+
92
+ constructor(
93
+ private readonly metrics: ActionsMetrics,
94
+ private readonly projectSlug: string,
95
+ ) {
96
+ this.labels = { [LABEL_PROJECT]: projectSlug }
97
+ }
98
+
99
+ public enqueued(count: number): void {
100
+ if (count > 0) {
101
+ this.metrics.eventsEnqueued.inc(this.labels, count)
102
+ }
103
+ }
104
+
105
+ public succeeded(count: number): void {
106
+ if (count > 0) {
107
+ this.metrics.eventsSucceeded.inc(this.labels, count)
108
+ }
109
+ }
110
+
111
+ public deliveryAttemptFailed(count: number): void {
112
+ if (count > 0) {
113
+ this.metrics.deliveryAttemptsFailed.inc(this.labels, count)
114
+ }
115
+ }
116
+
117
+ public failed(count: number): void {
118
+ if (count > 0) {
119
+ this.metrics.eventsFailed.inc(this.labels, count)
120
+ }
121
+ }
122
+
123
+ public heartbeat(): void {
124
+ this.metrics.workerHeartbeat.set(this.labels, Date.now() / 1000)
125
+ }
126
+
127
+ public crashed(): void {
128
+ this.metrics.workerCrashed.inc(this.labels, 1)
129
+ }
130
+
131
+ /** Drop the per-project heartbeat series when the worker stops, so a removed project doesn't look stuck. */
132
+ public dispose(): void {
133
+ this.metrics.workerHeartbeat.remove(this.projectSlug)
134
+ }
135
+ }
@@ -10,11 +10,18 @@ type ProcessBatchArgs = {
10
10
  db: DatabaseContext
11
11
  contentSchemaResolver: ContentSchemaResolver
12
12
  logger: Logger
13
+ project: { slug: string }
13
14
  }
14
15
 
15
16
  type ProcessBatchResult = {
16
- succeed: number
17
- failed: number
17
+ /** Events delivered successfully (terminal). */
18
+ succeeded: number
19
+ /** Failed delivery attempts that will be retried. */
20
+ retried: number
21
+ /** Events terminally failed after a delivery attempt (retries exhausted). */
22
+ failedAfterAttempt: number
23
+ /** Events terminally failed without a delivery attempt (target missing from schema). */
24
+ failedUnknownTarget: number
18
25
  backoffMs: number | undefined
19
26
  }
20
27
 
@@ -26,16 +33,22 @@ export class EventDispatcher {
26
33
  ) {
27
34
  }
28
35
 
29
- async processBatch({ db, contentSchemaResolver, logger }: ProcessBatchArgs): Promise<ProcessBatchResult> {
36
+ async processBatch({ db, contentSchemaResolver, logger, project }: ProcessBatchArgs): Promise<ProcessBatchResult> {
30
37
  const schema = (await contentSchemaResolver.getSchema({ db, normalize: true })).schema
31
- const batch = await this.eventsRepository.fetchBatch(schema.actions, db.client)
32
38
  const batchId = Math.random().toString().substring(2)
33
39
  const batchLogger = logger.child({ loc: 'Actions.EventDispatcher', batchId })
40
+ const batch = await this.eventsRepository.fetchBatch(schema.actions, db.client, batchLogger)
34
41
  if (!batch.ok) {
35
42
  batchLogger.debug('Nothing to process', {
36
43
  backoffMs: batch.backoffMs ?? 'undefined',
37
44
  })
38
- return { failed: 0, succeed: 0, backoffMs: batch.backoffMs }
45
+ return {
46
+ succeeded: 0,
47
+ retried: 0,
48
+ failedAfterAttempt: 0,
49
+ failedUnknownTarget: batch.unknownTargetFailed,
50
+ backoffMs: batch.backoffMs,
51
+ }
39
52
  }
40
53
  const { target, events } = batch
41
54
  try {
@@ -43,20 +56,32 @@ export class EventDispatcher {
43
56
  batchLogger.debug('Processing started', {
44
57
  events: batch.events.map(it => it.id),
45
58
  })
46
- const variables = await this.variablesManager.fetchVariables(db)
59
+ const variables = await this.variablesManager.fetchVariables(db, project.slug)
47
60
  const handledEvents = await handler.handle({ target, events, logger: batchLogger, variables })
48
- const [succeed, failed] = await this.eventsRepository.persistProcessed(db.client, handledEvents)
49
- batchLogger.debug('Processing done', { succeed, failed })
50
- return { succeed, failed, backoffMs: 0 }
61
+ const { succeeded, retried, failed } = await this.eventsRepository.persistProcessed(db.client, handledEvents, batchLogger)
62
+ batchLogger.debug('Processing done', { succeed: succeeded, failed })
63
+ return {
64
+ succeeded,
65
+ retried,
66
+ failedAfterAttempt: failed,
67
+ failedUnknownTarget: batch.unknownTargetFailed,
68
+ backoffMs: 0,
69
+ }
51
70
  } catch (e) {
52
71
  logger.error(e, { loc: 'EventDispatcher', batchId })
53
72
  const failedEvents = events.map((it): HandledEvent => ({
54
73
  row: it,
55
74
  result: { ok: false, errorMessage: `Internal error` },
56
75
  }))
57
- await this.eventsRepository.persistProcessed(db.client, failedEvents)
76
+ const { succeeded, retried, failed } = await this.eventsRepository.persistProcessed(db.client, failedEvents, batchLogger)
58
77
 
59
- return { succeed: 0, failed: batch.events.length, backoffMs: 0 }
78
+ return {
79
+ succeeded,
80
+ retried,
81
+ failedAfterAttempt: failed,
82
+ failedUnknownTarget: batch.unknownTargetFailed,
83
+ backoffMs: 0,
84
+ }
60
85
  }
61
86
  }
62
87
  }
@@ -3,6 +3,7 @@ import { EventRow, HandledEvent } from './types.js'
3
3
  import { Actions } from '@contember/schema'
4
4
  import { eventsToProcessSpecification, eventsToProcessStateSpecification } from '../model/EventsToProcessSpecification.js'
5
5
  import { notify } from '../utils/notifyChannel.js'
6
+ import { Logger } from '@contember/logger'
6
7
 
7
8
  const ACK_TIMEOUT_MS = 1_000 * 60 * 10 // 10 minutes
8
9
  const DEFAULT_REPEAT_INTERVAL_MS = 5_000 // 5 seconds
@@ -10,19 +11,32 @@ const DEFAULT_MAX_ATTEMPTS = 10
10
11
  const DEFAULT_BATCH_SIZE = 1
11
12
 
12
13
  export class EventsRepository {
13
- public async fetchBatch(actions: Actions.Schema, db: Client): Promise<FetchBatchResult> {
14
- const primaryEvent = (await this.fetchInternal(db, 1))[0]
15
- if (!primaryEvent) {
16
- return { ok: false, backoffMs: await this.fetchBackOff(db) }
17
- }
18
- const target = actions.targets[primaryEvent.target]
19
- if (!target) {
20
- await this.markFailedOnUnknownTarget(db, primaryEvent.target, primaryEvent.id)
21
- return await this.fetchBatch(actions, db)
14
+ public async fetchBatch(actions: Actions.Schema, db: Client, logger: Logger): Promise<FetchBatchResult> {
15
+ // Events referencing a target that no longer exists in the schema are terminally failed and
16
+ // skipped over; count them so the caller can report them as terminal failures.
17
+ let unknownTargetFailed = 0
18
+ while (true) {
19
+ const primaryEvent = (await this.fetchInternal(db, 1))[0]
20
+ if (!primaryEvent) {
21
+ return { ok: false, backoffMs: await this.fetchBackOff(db), unknownTargetFailed }
22
+ }
23
+ const target = actions.targets[primaryEvent.target]
24
+ if (!target) {
25
+ if (await this.markFailedOnUnknownTarget(db, primaryEvent.target, primaryEvent.id)) {
26
+ unknownTargetFailed++
27
+ // Never retried: the schema has to name the target again before such an event can be delivered.
28
+ logger.error('Action event failed permanently, its target is not in the schema', {
29
+ eventId: primaryEvent.id,
30
+ target: primaryEvent.target,
31
+ trigger: primaryEvent.trigger,
32
+ })
33
+ }
34
+ continue
35
+ }
36
+ const batchSize = (target.batchSize ?? DEFAULT_BATCH_SIZE) - 1
37
+ const batch = batchSize > 0 ? await this.fetchInternal(db, batchSize) : []
38
+ return { ok: true, events: [primaryEvent, ...batch], target, unknownTargetFailed }
22
39
  }
23
- const batchSize = (target.batchSize ?? DEFAULT_BATCH_SIZE) - 1
24
- const batch = batchSize > 0 ? await this.fetchInternal(db, batchSize) : []
25
- return { ok: true, events: [primaryEvent, ...batch], target }
26
40
  }
27
41
 
28
42
  private async fetchBackOff(db: Client): Promise<number | undefined> {
@@ -60,18 +74,34 @@ export class EventsRepository {
60
74
  .execute(db)
61
75
  }
62
76
 
63
- public async persistProcessed(db: Client, events: HandledEvent[]): Promise<[number, number]> {
77
+ public async persistProcessed(db: Client, events: HandledEvent[], logger: Logger): Promise<PersistProcessedResult> {
64
78
  return await db.transaction(async trx => {
65
79
  await trx.query(Connection.REPEATABLE_READ)
66
80
  const succeed = events.filter(it => it.result.ok)
67
81
  await this.markSucceed(trx, succeed.map(it => it.row))
68
82
 
83
+ let retried = 0
84
+ let failed = 0
69
85
  for (const event of events) {
70
86
  if (!event.result.ok) {
71
- await this.markFailed(trx, event)
87
+ const terminal = await this.markFailed(trx, event)
88
+ if (terminal) {
89
+ failed++
90
+ // Nothing else reports a given-up event; the target is named, never described by URL or headers.
91
+ logger.error('Action event failed permanently, no attempts left', {
92
+ eventId: event.row.id,
93
+ target: event.row.target,
94
+ trigger: event.row.trigger,
95
+ attempts: event.row.num_retries + 1,
96
+ code: event.result.code,
97
+ errorMessage: event.result.errorMessage,
98
+ })
99
+ } else {
100
+ retried++
101
+ }
72
102
  }
73
103
  }
74
- return [succeed.length, events.length - succeed.length]
104
+ return { succeeded: succeed.length, retried, failed }
75
105
  })
76
106
  }
77
107
 
@@ -124,42 +154,44 @@ export class EventsRepository {
124
154
  .execute(db)
125
155
  }
126
156
 
127
- private async markFailed(db: Client, event: HandledEvent): Promise<void> {
157
+ /** Returns `true` when the event reached a terminal `failed` state, `false` when it will be retried. */
158
+ private async markFailed(db: Client, event: HandledEvent): Promise<boolean> {
128
159
  const numRetries = event.row.num_retries + 1
129
160
  const now = new Date()
130
161
  const nextAttempt = new Date()
131
162
  nextAttempt.setTime(
132
163
  nextAttempt.getTime() + (event.target?.initialRepeatIntervalMs ?? DEFAULT_REPEAT_INTERVAL_MS) * Math.pow(2, event.row.num_retries),
133
164
  )
165
+ const terminal = numRetries >= (event.target?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS)
134
166
  await UpdateBuilder.create()
135
167
  .table('actions_event')
136
168
  .values<EventRow>({
137
169
  last_state_change: now,
138
170
  num_retries: numRetries,
139
171
  log: JSON.stringify([...event.row.log, event.result]) as any,
140
- ...(numRetries < (event.target?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS)
172
+ ...(terminal
141
173
  ? {
142
- state: 'retrying',
143
- visible_at: nextAttempt,
174
+ state: 'failed',
144
175
  }
145
176
  : {
146
- state: 'failed',
177
+ state: 'retrying',
178
+ visible_at: nextAttempt,
147
179
  }),
148
180
  })
149
181
  .where({ id: event.row.id })
150
182
  .execute(db)
183
+ return terminal
151
184
  }
152
185
 
153
- private async markFailedOnUnknownTarget(db: Client, targetName: string, primaryEventId: string) {
154
- await UpdateBuilder.create()
186
+ private async markFailedOnUnknownTarget(db: Client, targetName: string, primaryEventId: string): Promise<boolean> {
187
+ const affectedRows = await UpdateBuilder.create()
155
188
  .table('actions_event')
156
189
  .where(it =>
157
190
  it.or(it =>
158
191
  it
159
192
  .and(it =>
160
193
  it
161
- .in('state', ['retrying', 'created', 'processing'])
162
- .compare('visible_at', Operator.lte, 'now')
194
+ .in('state', ['processing'])
163
195
  .compare('target', Operator.eq, targetName)
164
196
  )
165
197
  .and(it =>
@@ -177,9 +209,19 @@ export class EventsRepository {
177
209
  resolved_at: new Date(),
178
210
  })
179
211
  .execute(db)
212
+ return affectedRows > 0
180
213
  }
181
214
  }
182
215
 
183
216
  export type FetchBatchResult =
184
- | { ok: true; events: EventRow[]; target: Actions.AnyTarget }
185
- | { ok: false; backoffMs?: number }
217
+ | { ok: true; events: EventRow[]; target: Actions.AnyTarget; unknownTargetFailed: number }
218
+ | { ok: false; backoffMs?: number; unknownTargetFailed: number }
219
+
220
+ export type PersistProcessedResult = {
221
+ /** Events delivered successfully (terminal). */
222
+ succeeded: number
223
+ /** Events whose delivery attempt failed and that will be retried later. */
224
+ retried: number
225
+ /** Events whose delivery attempt failed and that reached a terminal `failed` state. */
226
+ failed: number
227
+ }
@@ -4,17 +4,26 @@ import { ContentSchemaResolver } from '@contember/engine-http'
4
4
  import { Runnable, RunnableArgs, Running } from '@contember/engine-common'
5
5
  import { Listener } from '@contember/database'
6
6
  import { NOTIFY_CHANNEL_NAME } from '../utils/notifyChannel.js'
7
+ import { ActionsMetrics, ProjectActionsMetrics } from '../ActionsMetrics.js'
8
+
9
+ /**
10
+ * Upper bound on how long the loop parks while idle. Even when the queue is empty (and we'd
11
+ * otherwise wait indefinitely on a `pg_notify`), we re-check at least this often, so a lost
12
+ * notification self-heals and the heartbeat keeps refreshing while the worker is genuinely idle.
13
+ */
14
+ const MAX_IDLE_SLEEP_MS = 30_000
7
15
 
8
16
  export class ProjectDispatcherFactory {
9
17
  constructor(
10
18
  private readonly dispatcher: EventDispatcher,
19
+ private readonly metrics: ActionsMetrics,
11
20
  ) {
12
21
  }
13
22
 
14
23
  public create(
15
24
  { db, contentSchemaResolver, projectSlug }: { db: DatabaseContext; contentSchemaResolver: ContentSchemaResolver; projectSlug: string },
16
25
  ): ProjectDispatcher {
17
- return new ProjectDispatcher(this.dispatcher, db, contentSchemaResolver, projectSlug)
26
+ return new ProjectDispatcher(this.dispatcher, db, contentSchemaResolver, projectSlug, this.metrics.forProject(projectSlug))
18
27
  }
19
28
  }
20
29
 
@@ -24,6 +33,7 @@ export class ProjectDispatcher implements Runnable {
24
33
  private readonly db: DatabaseContext,
25
34
  private readonly contentSchemaResolver: ContentSchemaResolver,
26
35
  private readonly projectSlug: string,
36
+ private readonly metrics: ProjectActionsMetrics,
27
37
  ) {
28
38
  }
29
39
 
@@ -58,6 +68,7 @@ export class ProjectDispatcher implements Runnable {
58
68
  end: async () => {
59
69
  aborted = true
60
70
  await listener.end()
71
+ this.metrics.dispose()
61
72
  logger.info('Worker terminated', {
62
73
  succeed: succeedTotal,
63
74
  failed: failedTotal,
@@ -67,28 +78,32 @@ export class ProjectDispatcher implements Runnable {
67
78
 
68
79
  try {
69
80
  while (!aborted) {
70
- const { succeed, failed, backoffMs } = await this.dispatcher.processBatch({
81
+ this.metrics.heartbeat()
82
+ const { succeeded, retried, failedAfterAttempt, failedUnknownTarget, backoffMs } = await this.dispatcher.processBatch({
71
83
  db,
72
84
  contentSchemaResolver: this.contentSchemaResolver,
73
85
  logger,
86
+ project: { slug: this.projectSlug },
74
87
  })
75
- succeedTotal += succeed
76
- failedTotal += failed
88
+ const terminalFailed = failedAfterAttempt + failedUnknownTarget
89
+ this.metrics.succeeded(succeeded)
90
+ this.metrics.deliveryAttemptFailed(retried + failedAfterAttempt)
91
+ this.metrics.failed(terminalFailed)
92
+ succeedTotal += succeeded
93
+ failedTotal += terminalFailed
77
94
 
78
95
  // listener error occurred during batch processing, rethrow
79
96
  if (pendingError) {
80
97
  throw pendingError
81
98
  }
82
99
 
83
- // queue is empty, wait
100
+ // queue is empty (or next retry is in the future), wait — but never longer than
101
+ // MAX_IDLE_SLEEP_MS, so a lost notification self-heals and the heartbeat stays fresh.
84
102
  if (backoffMs !== 0) {
103
+ const waitMs = Math.min(backoffMs ?? MAX_IDLE_SLEEP_MS, MAX_IDLE_SLEEP_MS)
85
104
  await new Promise<void>((resolve, reject) => {
86
- let cleanup = () => {
87
- }
88
- if (backoffMs !== undefined) {
89
- const timeoutHandle = setTimeout(resolve, backoffMs)
90
- cleanup = () => clearTimeout(timeoutHandle)
91
- }
105
+ const timeoutHandle = setTimeout(resolve, waitMs)
106
+ const cleanup = () => clearTimeout(timeoutHandle)
92
107
  resolvePending = () => {
93
108
  resolve()
94
109
  cleanup()
@@ -103,6 +118,7 @@ export class ProjectDispatcher implements Runnable {
103
118
  onClose?.()
104
119
  } catch (e) {
105
120
  await listener.end()
121
+ this.metrics.crashed()
106
122
  onError(e)
107
123
  }
108
124
  })
@@ -3,6 +3,7 @@ import { EventRow, HandledEvent, InvokeHandler, InvokeHandlerArgs } from './type
3
3
  import { VariablesMap } from '../model/VariablesManager.js'
4
4
  import * as Typesafe from '@contember/typesafe'
5
5
  import { FetcherResponse, WebhookFetcher } from './WebhookFetcher.js'
6
+ import { Logger } from '@contember/logger'
6
7
 
7
8
  const DEFAULT_TIMEOUT_MS = 30_000 // 30 seconds
8
9
 
@@ -40,6 +41,8 @@ export class WebhookTargetHandler implements InvokeHandler<Actions.WebhookTarget
40
41
  eventResponseFactory = this.createResponseFactory({
41
42
  response: response,
42
43
  events,
44
+ target,
45
+ logger,
43
46
  })
44
47
  } catch (e) {
45
48
  logger.warn(e)
@@ -58,11 +61,20 @@ export class WebhookTargetHandler implements InvokeHandler<Actions.WebhookTarget
58
61
  return events.map(it => ({ target, row: it, result: { durationMs, ...eventResponseFactory(it) } }))
59
62
  }
60
63
 
61
- private createResponseFactory({ response, events }: {
64
+ private createResponseFactory({ response, events, target, logger }: {
62
65
  response: FetcherResponse
63
66
  events: EventRow[]
67
+ target: Actions.WebhookTarget
68
+ logger: Logger
64
69
  }): EventResponseFactory {
65
70
  if (!response.ok) {
71
+ // The target is identified by name only: its URL and headers may carry credentials.
72
+ logger.warn('Webhook target responded with an error status', {
73
+ target: target.name,
74
+ status: response.status,
75
+ statusText: response.statusText,
76
+ events: events.map(it => it.id),
77
+ })
66
78
  return () => ({
67
79
  ok: false,
68
80
  code: response.status,
@@ -95,6 +107,13 @@ export class WebhookTargetHandler implements InvokeHandler<Actions.WebhookTarget
95
107
  const missingEvents = Object.keys(failedEvents).filter(it => !eventsInBatch.has(it))
96
108
 
97
109
  if (missingEvents.length) {
110
+ // Only the count of unknown ids: they are strings the target chose, not ours.
111
+ logger.warn('Webhook target reported failures for event ids outside the batch', {
112
+ target: target.name,
113
+ status: response.status,
114
+ unknownEventIds: missingEvents.length,
115
+ events: events.map(it => it.id),
116
+ })
98
117
  return () => ({
99
118
  ok: false,
100
119
  code: response.status,
@@ -103,6 +122,16 @@ export class WebhookTargetHandler implements InvokeHandler<Actions.WebhookTarget
103
122
  })
104
123
  }
105
124
 
125
+ const failedIds = events.filter(it => failedEvents[it.id]).map(it => it.id)
126
+ if (failedIds.length) {
127
+ // Per-event errors come from the target's payload and stay in the event log, not here.
128
+ logger.warn('Webhook target reported failed events', {
129
+ target: target.name,
130
+ status: response.status,
131
+ events: failedIds,
132
+ })
133
+ }
134
+
106
135
  return it => {
107
136
  if (!failedEvents[it.id]) {
108
137
  return { ok: true, code: response.status }
@@ -114,6 +143,12 @@ export class WebhookTargetHandler implements InvokeHandler<Actions.WebhookTarget
114
143
  }
115
144
  }
116
145
  } catch (e: any) {
146
+ // The parser message quotes the offending payload, so it stays in the event log only.
147
+ logger.warn('Webhook target returned a malformed response', {
148
+ target: target.name,
149
+ status: response.status,
150
+ events: events.map(it => it.id),
151
+ })
117
152
  return () => ({
118
153
  ok: false,
119
154
  code: response.status,