@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.
- package/auto-pair.ts +5 -0
- package/config-setup.ts +113 -17
- package/gateway/analytics.ts +83 -0
- package/gateway/clawhub2gateway.ts +15 -0
- package/gateway/cron-delivery.ts +13 -2
- package/gateway/cron-telemetry.test.ts +407 -0
- package/gateway/cron-telemetry.ts +253 -0
- package/gateway/index.ts +33 -0
- package/gateway/offline-push.test.ts +209 -0
- package/gateway/offline-push.ts +107 -12
- package/gateway/otel.test.ts +88 -0
- package/gateway/otel.ts +57 -0
- package/gateway/plugins.ts +3 -0
- package/gateway/posthog.test.ts +73 -0
- package/gateway/posthog.ts +61 -0
- package/gateway/telemetry-config.test.ts +58 -0
- package/gateway/telemetry-config.ts +27 -0
- package/index.ts +7 -1
- package/model-gateway-setup.ts +5 -7
- package/openclaw.plugin.json +6 -1
- package/outbound.ts +67 -32
- package/package.json +7 -1
- package/tools/clawly-send-image.ts +228 -0
- package/tools/index.ts +2 -0
|
@@ -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
|
+
}
|