@2en/clawly-plugins 1.26.0-beta.0 → 1.26.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.
@@ -0,0 +1,407 @@
1
+ import {afterEach, beforeEach, describe, expect, mock, test} from 'bun:test'
2
+ import type {PluginApi} from '../types'
3
+ import {
4
+ markCronDelivered,
5
+ markCronDeliverySkipped,
6
+ markCronPushSent,
7
+ markCronPushSkipped,
8
+ registerCronTelemetry,
9
+ } from './cron-telemetry'
10
+
11
+ // ── Mock OTel logger ─────────────────────────────────────────────
12
+
13
+ type LogRecord = {
14
+ severityNumber: number
15
+ severityText: string
16
+ body: string
17
+ attributes: Record<string, unknown>
18
+ }
19
+
20
+ let otelRecords: LogRecord[] = []
21
+
22
+ let posthogEvents: {event: string; properties?: Record<string, unknown>}[] = []
23
+
24
+ mock.module('@opentelemetry/api-logs', () => ({
25
+ SeverityNumber: {INFO: 9},
26
+ }))
27
+
28
+ mock.module('./posthog', () => ({
29
+ captureEvent: (event: string, properties?: Record<string, unknown>) =>
30
+ posthogEvents.push({event, properties}),
31
+ }))
32
+
33
+ mock.module('./otel', () => ({
34
+ getOtelLogger: () => ({
35
+ emit: (record: LogRecord) => otelRecords.push(record),
36
+ }),
37
+ }))
38
+
39
+ // ── Helpers ──────────────────────────────────────────────────────
40
+
41
+ type AfterToolCallHandler = (event: Record<string, unknown>) => void
42
+ type AgentEndHandler = (event: Record<string, unknown>, ctx?: Record<string, unknown>) => void
43
+
44
+ let afterToolCallHandler: AfterToolCallHandler
45
+ let agentEndHandler: AgentEndHandler
46
+ let logs: string[]
47
+
48
+ function createMockApi(pluginConfig?: Record<string, unknown>): PluginApi {
49
+ logs = []
50
+ otelRecords = []
51
+ posthogEvents = []
52
+ return {
53
+ id: 'test',
54
+ name: 'Test',
55
+ pluginConfig,
56
+ runtime: {},
57
+ logger: {
58
+ info: (msg: string) => logs.push(msg),
59
+ warn: (msg: string) => logs.push(`WARN: ${msg}`),
60
+ error: (msg: string) => logs.push(`ERROR: ${msg}`),
61
+ },
62
+ registerGatewayMethod: () => {},
63
+ registerTool: () => {},
64
+ registerCommand: () => {},
65
+ registerChannel: () => {},
66
+ registerHttpRoute: () => {},
67
+ on: (hookName: string, handler: (...args: any[]) => any, _opts?: {priority?: number}) => {
68
+ if (hookName === 'after_tool_call') afterToolCallHandler = handler as AfterToolCallHandler
69
+ if (hookName === 'agent_end') agentEndHandler = handler as AgentEndHandler
70
+ },
71
+ }
72
+ }
73
+
74
+ // ── Tests ────────────────────────────────────────────────────────
75
+
76
+ describe('cron-telemetry', () => {
77
+ beforeEach(() => {
78
+ registerCronTelemetry(createMockApi({instanceId: 'inst-001'}))
79
+ })
80
+
81
+ describe('after_tool_call — cron add', () => {
82
+ test('captures job creation with id, name, and schedule', () => {
83
+ afterToolCallHandler({
84
+ toolName: 'cron',
85
+ params: {
86
+ action: 'add',
87
+ job: {name: 'daily-check', schedule: '0 9 * * *'},
88
+ },
89
+ result: {id: 'job-123'},
90
+ })
91
+
92
+ expect(logs.some((l) => l.includes('job created') && l.includes('job-123'))).toBe(true)
93
+ expect(logs.some((l) => l.includes('daily-check'))).toBe(true)
94
+ expect(logs.some((l) => l.includes('0 9 * * *'))).toBe(true)
95
+ })
96
+
97
+ test('emits OTel LogRecord with structured attributes', () => {
98
+ afterToolCallHandler({
99
+ toolName: 'cron',
100
+ params: {
101
+ action: 'add',
102
+ job: {name: 'daily-check', schedule: '0 9 * * *'},
103
+ },
104
+ result: {id: 'job-123'},
105
+ })
106
+
107
+ expect(otelRecords).toHaveLength(1)
108
+ const r = otelRecords[0]
109
+ expect(r.body).toBe('cron.lifecycle.created')
110
+ expect(r.attributes['cron.lifecycle']).toBe('created')
111
+ expect(r.attributes['cron.job.id']).toBe('job-123')
112
+ expect(r.attributes['cron.job.name']).toBe('daily-check')
113
+ expect(r.attributes['cron.job.schedule']).toBe('0 9 * * *')
114
+ })
115
+
116
+ test('handles missing job name and schedule', () => {
117
+ afterToolCallHandler({
118
+ toolName: 'cron',
119
+ params: {action: 'add', job: {}},
120
+ result: {id: 'job-456'},
121
+ })
122
+
123
+ expect(otelRecords).toHaveLength(1)
124
+ expect(otelRecords[0].attributes['cron.job.id']).toBe('job-456')
125
+ expect(otelRecords[0].attributes['cron.job.name']).toBeUndefined()
126
+ expect(otelRecords[0].attributes['cron.job.schedule']).toBeUndefined()
127
+ })
128
+
129
+ test('ignores non-cron tools', () => {
130
+ afterToolCallHandler({
131
+ toolName: 'other-tool',
132
+ params: {action: 'add'},
133
+ result: {id: 'x'},
134
+ })
135
+
136
+ expect(otelRecords).toHaveLength(0)
137
+ })
138
+
139
+ test('ignores cron tool with non-add action', () => {
140
+ afterToolCallHandler({
141
+ toolName: 'cron',
142
+ params: {action: 'list'},
143
+ result: {},
144
+ })
145
+
146
+ expect(otelRecords).toHaveLength(0)
147
+ })
148
+ })
149
+
150
+ describe('after_tool_call — cron remove', () => {
151
+ test('captures job deletion with structured attributes', () => {
152
+ afterToolCallHandler({
153
+ toolName: 'cron',
154
+ params: {action: 'remove', id: 'job-789'},
155
+ })
156
+
157
+ expect(logs.some((l) => l.includes('job deleted') && l.includes('job-789'))).toBe(true)
158
+ expect(otelRecords).toHaveLength(1)
159
+ expect(otelRecords[0].attributes['cron.lifecycle']).toBe('deleted')
160
+ expect(otelRecords[0].attributes['cron.job.id']).toBe('job-789')
161
+ })
162
+
163
+ test('handles jobId param variant', () => {
164
+ afterToolCallHandler({
165
+ toolName: 'cron',
166
+ params: {action: 'remove', jobId: 'job-abc'},
167
+ })
168
+
169
+ expect(otelRecords[0].attributes['cron.job.id']).toBe('job-abc')
170
+ })
171
+ })
172
+
173
+ describe('agent_end — cron execution', () => {
174
+ test('captures successful cron execution with structured attributes', () => {
175
+ agentEndHandler({messages: []}, {trigger: 'cron', sessionKey: 'agent:clawly:cron:job-123'})
176
+
177
+ expect(
178
+ logs.some(
179
+ (l) => l.includes('job executed') && l.includes('job-123') && l.includes('success=true'),
180
+ ),
181
+ ).toBe(true)
182
+ expect(otelRecords).toHaveLength(1)
183
+ const a = otelRecords[0].attributes
184
+ expect(a['cron.lifecycle']).toBe('executed')
185
+ expect(a['cron.job.id']).toBe('job-123')
186
+ expect(a['cron.execution.success']).toBe(true)
187
+ expect(a['cron.delivery.app_message_sent']).toBe(false)
188
+ expect(a['cron.delivery.push_notification_sent']).toBe(false)
189
+ expect(a['instance.id']).toBe('inst-001')
190
+ })
191
+
192
+ test('captures failed cron execution', () => {
193
+ agentEndHandler(
194
+ {messages: [], error: 'timeout'},
195
+ {trigger: 'cron', sessionKey: 'agent:clawly:cron:job-456'},
196
+ )
197
+
198
+ expect(otelRecords[0].attributes['cron.execution.success']).toBe(false)
199
+ expect(otelRecords[0].attributes['cron.execution.error']).toBe('timeout')
200
+ })
201
+
202
+ test('ignores non-cron sessions', () => {
203
+ agentEndHandler({messages: []}, {trigger: 'user', sessionKey: 'agent:clawly:main'})
204
+
205
+ expect(otelRecords).toHaveLength(0)
206
+ })
207
+
208
+ test('ignores missing sessionKey', () => {
209
+ agentEndHandler({messages: []}, {trigger: 'cron'})
210
+
211
+ expect(otelRecords).toHaveLength(0)
212
+ })
213
+
214
+ test('detects cron session by sessionKey prefix even without trigger field', () => {
215
+ agentEndHandler({messages: []}, {sessionKey: 'agent:clawly:cron:job-notrigger'})
216
+
217
+ expect(otelRecords).toHaveLength(1)
218
+ expect(otelRecords[0].attributes['cron.lifecycle']).toBe('executed')
219
+ expect(otelRecords[0].attributes['cron.job.id']).toBe('job-notrigger')
220
+ })
221
+
222
+ test('reports durationMs when present', () => {
223
+ agentEndHandler(
224
+ {messages: [], durationMs: 1500},
225
+ {trigger: 'cron', sessionKey: 'agent:clawly:cron:job-x'},
226
+ )
227
+
228
+ expect(otelRecords[0].attributes['cron.execution.duration_ms']).toBe(1500)
229
+ expect(logs.some((l) => l.includes('duration=1500ms'))).toBe(true)
230
+ })
231
+ })
232
+
233
+ describe('cross-hook coordination flags', () => {
234
+ test('markCronDelivered sets appMessage flag', () => {
235
+ const sk = 'agent:clawly:cron:job-d1'
236
+ markCronDelivered(sk)
237
+
238
+ agentEndHandler({messages: []}, {trigger: 'cron', sessionKey: sk})
239
+
240
+ expect(otelRecords[0].attributes['cron.delivery.app_message_sent']).toBe(true)
241
+ expect(otelRecords[0].attributes['cron.delivery.push_notification_sent']).toBe(false)
242
+ })
243
+
244
+ test('markCronPushSent sets push flag', () => {
245
+ const sk = 'agent:clawly:cron:job-p1'
246
+ markCronPushSent(sk)
247
+
248
+ agentEndHandler({messages: []}, {trigger: 'cron', sessionKey: sk})
249
+
250
+ expect(otelRecords[0].attributes['cron.delivery.push_notification_sent']).toBe(true)
251
+ expect(otelRecords[0].attributes['cron.delivery.app_message_sent']).toBe(false)
252
+ })
253
+
254
+ test('both flags set when both hooks fire', () => {
255
+ const sk = 'agent:clawly:cron:job-both'
256
+ markCronDelivered(sk)
257
+ markCronPushSent(sk)
258
+
259
+ agentEndHandler({messages: []}, {trigger: 'cron', sessionKey: sk})
260
+
261
+ const a = otelRecords[0].attributes
262
+ expect(a['cron.delivery.app_message_sent']).toBe(true)
263
+ expect(a['cron.delivery.push_notification_sent']).toBe(true)
264
+ })
265
+
266
+ test('flags are cleaned up after consumption', () => {
267
+ const sk = 'agent:clawly:cron:job-cleanup'
268
+ markCronDelivered(sk)
269
+
270
+ // First call consumes
271
+ agentEndHandler({messages: []}, {trigger: 'cron', sessionKey: sk})
272
+
273
+ // Second call should have default flags
274
+ agentEndHandler({messages: []}, {trigger: 'cron', sessionKey: sk})
275
+ expect(otelRecords[1].attributes['cron.delivery.app_message_sent']).toBe(false)
276
+ })
277
+ })
278
+
279
+ describe('agent.id attribute', () => {
280
+ test('includes agent.id from ctx on executed events', () => {
281
+ agentEndHandler(
282
+ {messages: []},
283
+ {trigger: 'cron', sessionKey: 'agent:clawly:cron:job-aid', agentId: 'agent-42'},
284
+ )
285
+
286
+ expect(otelRecords[0].attributes['agent.id']).toBe('agent-42')
287
+ })
288
+
289
+ test('omits agent.id when ctx has no agentId', () => {
290
+ agentEndHandler({messages: []}, {trigger: 'cron', sessionKey: 'agent:clawly:cron:job-noid'})
291
+
292
+ expect(otelRecords[0].attributes['agent.id']).toBeUndefined()
293
+ })
294
+ })
295
+
296
+ describe('client.online attribute', () => {
297
+ test('includes client.online when push is skipped with clientOnline', () => {
298
+ const sk = 'agent:clawly:cron:job-online'
299
+ markCronPushSkipped(sk, 'client online', true)
300
+
301
+ agentEndHandler({messages: []}, {trigger: 'cron', sessionKey: sk})
302
+
303
+ expect(otelRecords[0].attributes['client.online']).toBe(true)
304
+ })
305
+
306
+ test('includes client.online=false when push is skipped offline', () => {
307
+ const sk = 'agent:clawly:cron:job-offline'
308
+ markCronPushSkipped(sk, 'filtered: empty assistant', false)
309
+
310
+ agentEndHandler({messages: []}, {trigger: 'cron', sessionKey: sk})
311
+
312
+ expect(otelRecords[0].attributes['client.online']).toBe(false)
313
+ })
314
+
315
+ test('omits client.online when not set', () => {
316
+ agentEndHandler(
317
+ {messages: []},
318
+ {trigger: 'cron', sessionKey: 'agent:clawly:cron:job-noonline'},
319
+ )
320
+
321
+ expect(otelRecords[0].attributes['client.online']).toBeUndefined()
322
+ })
323
+ })
324
+
325
+ describe('skip reason attributes', () => {
326
+ test('delivery skip reason is present when delivery is skipped', () => {
327
+ const sk = 'agent:clawly:cron:job-dskip'
328
+ markCronDeliverySkipped(sk, 'no assistant message')
329
+
330
+ agentEndHandler({messages: []}, {trigger: 'cron', sessionKey: sk})
331
+
332
+ const a = otelRecords[0].attributes
333
+ expect(a['cron.delivery.app_message_sent']).toBe(false)
334
+ expect(a['cron.delivery.app_message_skip_reason']).toBe('no assistant message')
335
+ })
336
+
337
+ test('push skip reason is present when push is skipped', () => {
338
+ const sk = 'agent:clawly:cron:job-pskip'
339
+ markCronPushSkipped(sk, 'send failed', false)
340
+
341
+ agentEndHandler({messages: []}, {trigger: 'cron', sessionKey: sk})
342
+
343
+ const a = otelRecords[0].attributes
344
+ expect(a['cron.delivery.push_notification_sent']).toBe(false)
345
+ expect(a['cron.delivery.push_skip_reason']).toBe('send failed')
346
+ })
347
+
348
+ test('no skip reasons when delivery and push succeed', () => {
349
+ const sk = 'agent:clawly:cron:job-success'
350
+ markCronDelivered(sk)
351
+ markCronPushSent(sk)
352
+
353
+ agentEndHandler({messages: []}, {trigger: 'cron', sessionKey: sk})
354
+
355
+ const a = otelRecords[0].attributes
356
+ expect(a['cron.delivery.app_message_sent']).toBe(true)
357
+ expect(a['cron.delivery.app_message_skip_reason']).toBeUndefined()
358
+ expect(a['cron.delivery.push_notification_sent']).toBe(true)
359
+ expect(a['cron.delivery.push_skip_reason']).toBeUndefined()
360
+ })
361
+
362
+ test('delivery skipped + push sent is consistent', () => {
363
+ const sk = 'agent:clawly:cron:job-mixed'
364
+ markCronDeliverySkipped(sk, 'no agentId')
365
+ markCronPushSent(sk)
366
+
367
+ agentEndHandler({messages: []}, {trigger: 'cron', sessionKey: sk})
368
+
369
+ const a = otelRecords[0].attributes
370
+ expect(a['cron.delivery.app_message_sent']).toBe(false)
371
+ expect(a['cron.delivery.app_message_skip_reason']).toBe('no agentId')
372
+ expect(a['cron.delivery.push_notification_sent']).toBe(true)
373
+ expect(a['cron.delivery.push_skip_reason']).toBeUndefined()
374
+ })
375
+ })
376
+
377
+ describe('PostHog events', () => {
378
+ test('emits posthog event for cron created', () => {
379
+ afterToolCallHandler({
380
+ toolName: 'cron',
381
+ params: {action: 'add', job: {name: 'test'}},
382
+ result: {id: 'job-ph1'},
383
+ })
384
+
385
+ expect(posthogEvents).toHaveLength(1)
386
+ expect(posthogEvents[0].event).toBe('cron.created')
387
+ expect(posthogEvents[0].properties?.['cron.job.id']).toBe('job-ph1')
388
+ })
389
+
390
+ test('emits posthog event for cron executed', () => {
391
+ agentEndHandler({messages: []}, {trigger: 'cron', sessionKey: 'agent:clawly:cron:job-ph2'})
392
+
393
+ expect(posthogEvents).toHaveLength(1)
394
+ expect(posthogEvents[0].event).toBe('cron.executed')
395
+ })
396
+
397
+ test('emits posthog event for cron deleted', () => {
398
+ afterToolCallHandler({
399
+ toolName: 'cron',
400
+ params: {action: 'remove', id: 'job-ph3'},
401
+ })
402
+
403
+ expect(posthogEvents).toHaveLength(1)
404
+ expect(posthogEvents[0].event).toBe('cron.deleted')
405
+ })
406
+ })
407
+ })
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Cron lifecycle telemetry — captures creation, execution, and deletion
3
+ * events for cron jobs via the plugin hook system.
4
+ *
5
+ * Events are emitted as OTel LogRecords via @opentelemetry/sdk-logs when
6
+ * plugin telemetry config contains Axiom credentials.
7
+ * Human-readable logs are always emitted via api.logger regardless.
8
+ *
9
+ * Hooks:
10
+ * - after_tool_call — captures cron add/remove tool results
11
+ * - agent_end (priority 100) — captures cron execution outcomes
12
+ * Runs after cron-delivery and offline-push so delivery/push flags are set.
13
+ *
14
+ * Cross-hook coordination:
15
+ * - markCronDelivered(sessionKey) — called by cron-delivery on successful inject
16
+ * - markCronDeliverySkipped(sessionKey, reason) — called by cron-delivery on skip
17
+ * - markCronPushSent(sessionKey) — called by offline-push on successful push
18
+ * - markCronPushSkipped(sessionKey, reason, clientOnline) — called by offline-push on skip
19
+ */
20
+
21
+ import {SeverityNumber} from '@opentelemetry/api-logs'
22
+ import type {PluginApi} from '../types'
23
+ import {getOtelLogger} from './otel'
24
+ import {captureEvent} from './posthog'
25
+ import {readTelemetryPluginConfig} from './telemetry-config'
26
+
27
+ // ── Structured telemetry attributes (OTel LogRecord) ────────────
28
+
29
+ export type CronTelemetryAttributes = {
30
+ 'cron.lifecycle': 'created' | 'executed' | 'deleted'
31
+ 'cron.job.id': string
32
+ 'cron.job.name'?: string
33
+ 'cron.job.schedule'?: string
34
+ 'cron.execution.success'?: boolean
35
+ 'cron.execution.error'?: string
36
+ 'cron.execution.duration_ms'?: number
37
+ 'cron.delivery.app_message_sent'?: boolean
38
+ 'cron.delivery.app_message_skip_reason'?: string
39
+ 'cron.delivery.push_notification_sent'?: boolean
40
+ 'cron.delivery.push_skip_reason'?: string
41
+ 'instance.id'?: string
42
+ 'agent.id'?: string
43
+ 'client.online'?: boolean
44
+ }
45
+
46
+ // ── Cross-hook coordination flags ───────────────────────────────
47
+
48
+ type CronRunFlags = {
49
+ delivered: boolean
50
+ deliverySkipReason?: string
51
+ pushSent: boolean
52
+ pushSkipReason?: string
53
+ clientOnline?: boolean
54
+ createdAt: number
55
+ }
56
+
57
+ const cronRunFlags = new Map<string, CronRunFlags>()
58
+
59
+ /** Max age for flag entries (10 minutes). Safety net against leaks. */
60
+ const FLAG_TTL_MS = 10 * 60 * 1000
61
+
62
+ function getOrCreate(sessionKey: string): CronRunFlags {
63
+ let flags = cronRunFlags.get(sessionKey)
64
+ if (!flags) {
65
+ // Evict stale entries on insertion to bound map size
66
+ if (cronRunFlags.size >= 50) {
67
+ const now = Date.now()
68
+ for (const [key, val] of cronRunFlags) {
69
+ if (now - val.createdAt > FLAG_TTL_MS) cronRunFlags.delete(key)
70
+ }
71
+ }
72
+ flags = {delivered: false, pushSent: false, createdAt: Date.now()}
73
+ cronRunFlags.set(sessionKey, flags)
74
+ }
75
+ return flags
76
+ }
77
+
78
+ export function markCronDelivered(sessionKey: string) {
79
+ getOrCreate(sessionKey).delivered = true
80
+ }
81
+
82
+ export function markCronDeliverySkipped(sessionKey: string, reason: string) {
83
+ const flags = getOrCreate(sessionKey)
84
+ flags.delivered = false
85
+ flags.deliverySkipReason = reason
86
+ }
87
+
88
+ export function markCronPushSent(sessionKey: string) {
89
+ getOrCreate(sessionKey).pushSent = true
90
+ }
91
+
92
+ export function markCronPushSkipped(sessionKey: string, reason: string, clientOnline?: boolean) {
93
+ const flags = getOrCreate(sessionKey)
94
+ flags.pushSent = false
95
+ flags.pushSkipReason = reason
96
+ if (clientOnline != null) flags.clientOnline = clientOnline
97
+ }
98
+
99
+ // ── Helpers ──────────────────────────────────────────────────────
100
+
101
+ function extractJobId(sessionKey: string): string | null {
102
+ const prefix = 'agent:clawly:cron:'
103
+ if (!sessionKey.startsWith(prefix)) return null
104
+ return sessionKey.slice(prefix.length) || null
105
+ }
106
+
107
+ function emit(attributes: CronTelemetryAttributes, api: PluginApi) {
108
+ // Emit structured OTel LogRecord (if provider is initialized)
109
+ const otelLogger = getOtelLogger()
110
+ if (otelLogger) {
111
+ otelLogger.emit({
112
+ severityNumber: SeverityNumber.INFO,
113
+ severityText: 'INFO',
114
+ body: `cron.lifecycle.${attributes['cron.lifecycle']}`,
115
+ attributes: attributes as Record<string, unknown>,
116
+ })
117
+ }
118
+
119
+ // Emit PostHog event
120
+ captureEvent(`cron.${attributes['cron.lifecycle']}`, attributes as Record<string, unknown>)
121
+
122
+ // Always log human-readable summary
123
+ const a = attributes
124
+ switch (a['cron.lifecycle']) {
125
+ case 'created':
126
+ api.logger.info(
127
+ `cron-telemetry: job created jobId=${a['cron.job.id']}${a['cron.job.name'] ? ` name="${a['cron.job.name']}"` : ''}${a['cron.job.schedule'] ? ` schedule="${a['cron.job.schedule']}"` : ''}`,
128
+ )
129
+ break
130
+ case 'deleted':
131
+ api.logger.info(`cron-telemetry: job deleted jobId=${a['cron.job.id']}`)
132
+ break
133
+ case 'executed':
134
+ api.logger.info(
135
+ `cron-telemetry: job executed jobId=${a['cron.job.id']} success=${a['cron.execution.success']} appMessage=${a['cron.delivery.app_message_sent']} push=${a['cron.delivery.push_notification_sent']}${a['cron.execution.duration_ms'] != null ? ` duration=${a['cron.execution.duration_ms']}ms` : ''}`,
136
+ )
137
+ break
138
+ }
139
+ }
140
+
141
+ // ── Registration ─────────────────────────────────────────────────
142
+
143
+ export function registerCronTelemetry(api: PluginApi) {
144
+ // 1. after_tool_call: capture cron add/remove
145
+ api.on('after_tool_call', (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
146
+ api.logger.info(
147
+ `cron-telemetry[debug]: after_tool_call fired toolName=${String(event.toolName)} params.action=${String((event.params as any)?.action)} ctx=${JSON.stringify(ctx ?? {})}`,
148
+ )
149
+ if (event.toolName !== 'cron') return
150
+
151
+ const params = (event.params ?? {}) as Record<string, unknown>
152
+ const result = event.result as Record<string, unknown> | undefined
153
+ api.logger.info(
154
+ `cron-telemetry[debug]: cron tool detected action=${String(params.action)} result=${JSON.stringify(result ?? null)} error=${String(event.error ?? 'none')}`,
155
+ )
156
+
157
+ if (params.action === 'add' && result) {
158
+ const job = (params.job ?? params) as Record<string, unknown>
159
+ const jobId =
160
+ typeof result.id === 'string'
161
+ ? result.id
162
+ : typeof result.jobId === 'string'
163
+ ? result.jobId
164
+ : 'unknown'
165
+ const jobName = typeof job.name === 'string' ? job.name : undefined
166
+ const schedule = typeof job.schedule === 'string' ? job.schedule : undefined
167
+
168
+ emit(
169
+ {
170
+ 'cron.lifecycle': 'created',
171
+ 'cron.job.id': jobId,
172
+ ...(jobName ? {'cron.job.name': jobName} : {}),
173
+ ...(schedule ? {'cron.job.schedule': schedule} : {}),
174
+ },
175
+ api,
176
+ )
177
+ }
178
+
179
+ if (params.action === 'remove') {
180
+ const jobId =
181
+ typeof params.id === 'string'
182
+ ? params.id
183
+ : typeof params.jobId === 'string'
184
+ ? params.jobId
185
+ : 'unknown'
186
+
187
+ emit({'cron.lifecycle': 'deleted', 'cron.job.id': jobId}, api)
188
+ }
189
+ })
190
+
191
+ // Read instance.id once at registration time
192
+ const instanceId =
193
+ readTelemetryPluginConfig(api.pluginConfig).instanceId ?? process.env.INSTANCE_ID
194
+
195
+ // 2. agent_end: capture cron execution (priority 100 = runs after delivery/push hooks)
196
+ api.on(
197
+ 'agent_end',
198
+ (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
199
+ const sessionKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey : undefined
200
+ api.logger.info(
201
+ `cron-telemetry[debug]: agent_end fired trigger=${String(ctx?.trigger ?? 'undefined')} sessionKey=${sessionKey ?? 'undefined'} agentId=${String(ctx?.agentId ?? 'undefined')} eventKeys=${Object.keys(event).join(',')}`,
202
+ )
203
+
204
+ // Detect cron sessions by sessionKey prefix (consistent with cron-delivery)
205
+ // instead of ctx.trigger which may not be set by all OpenClaw versions.
206
+ if (!sessionKey?.startsWith('agent:clawly:cron:')) return
207
+
208
+ const jobId = extractJobId(sessionKey)
209
+ if (!jobId) {
210
+ api.logger.info(
211
+ `cron-telemetry[debug]: agent_end skipped — extractJobId returned null for sessionKey=${sessionKey}`,
212
+ )
213
+ return
214
+ }
215
+
216
+ const agentId = typeof ctx.agentId === 'string' ? ctx.agentId : undefined
217
+ const flags: CronRunFlags = cronRunFlags.get(sessionKey) ?? {
218
+ delivered: false,
219
+ pushSent: false,
220
+ }
221
+ cronRunFlags.delete(sessionKey)
222
+
223
+ const success = event.error == null
224
+ const error = typeof event.error === 'string' ? event.error : undefined
225
+ const durationMs = typeof event.durationMs === 'number' ? event.durationMs : undefined
226
+
227
+ emit(
228
+ {
229
+ 'cron.lifecycle': 'executed',
230
+ 'cron.job.id': jobId,
231
+ 'cron.execution.success': success,
232
+ ...(error ? {'cron.execution.error': error} : {}),
233
+ ...(durationMs != null ? {'cron.execution.duration_ms': durationMs} : {}),
234
+ 'cron.delivery.app_message_sent': flags.delivered,
235
+ ...(flags.deliverySkipReason
236
+ ? {'cron.delivery.app_message_skip_reason': flags.deliverySkipReason}
237
+ : {}),
238
+ 'cron.delivery.push_notification_sent': flags.pushSent,
239
+ ...(flags.pushSkipReason ? {'cron.delivery.push_skip_reason': flags.pushSkipReason} : {}),
240
+ ...(instanceId ? {'instance.id': instanceId} : {}),
241
+ ...(agentId ? {'agent.id': agentId} : {}),
242
+ ...(flags.clientOnline != null ? {'client.online': flags.clientOnline} : {}),
243
+ },
244
+ api,
245
+ )
246
+ },
247
+ {priority: 100},
248
+ )
249
+
250
+ api.logger.info(
251
+ 'cron-telemetry: registered after_tool_call + agent_end hooks (debug-instrumented)',
252
+ )
253
+ }