@2en/clawly-plugins 1.26.0-beta.0 → 1.26.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/auto-pair.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import {captureEvent} from './gateway/posthog'
1
2
  import type {PluginApi} from './index'
2
3
 
3
4
  const AUTO_APPROVE_CLIENT_IDS = new Set(['openclaw-ios'])
@@ -49,6 +50,10 @@ export function registerAutoPair(api: PluginApi) {
49
50
  if (req.clientId && AUTO_APPROVE_CLIENT_IDS.has(req.clientId)) {
50
51
  const result = await sdk.approveDevicePairing(req.requestId)
51
52
  if (result) {
53
+ captureEvent('device.paired', {
54
+ client_id: req.clientId,
55
+ platform: result.device.platform ?? 'unknown',
56
+ })
52
57
  api.logger.info(
53
58
  `auto-pair: approved device=${result.device.deviceId} ` +
54
59
  `name=${result.device.displayName ?? 'unknown'} ` +
package/config-setup.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  } from './model-gateway-setup'
24
24
 
25
25
  export interface ConfigPluginConfig {
26
+ instanceId?: string
26
27
  agentId?: string
27
28
  agentName?: string
28
29
  workspaceDir?: string
@@ -32,6 +33,10 @@ export interface ConfigPluginConfig {
32
33
  elevenlabsVoiceId?: string
33
34
  modelGatewayBaseUrl?: string
34
35
  modelGatewayToken?: string
36
+ otelToken?: string
37
+ otelDataset?: string
38
+ posthogApiKey?: string
39
+ posthogHost?: string
35
40
  }
36
41
 
37
42
  function toPC(api: PluginApi): ConfigPluginConfig {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Broad PostHog analytics — captures agent turns, LLM usage, and tool calls.
3
+ *
4
+ * Hooks:
5
+ * - agent_end — tracks every agent turn completion (trigger, duration, success)
6
+ * - llm_output — tracks LLM token usage per response (provider, model, tokens)
7
+ * - after_tool_call — tracks tool usage (tool name, duration, success)
8
+ *
9
+ * Cron-specific lifecycle events (created/executed/deleted) are handled by
10
+ * cron-telemetry.ts and are NOT duplicated here.
11
+ *
12
+ * Other modules (offline-push, auto-pair, plugins, clawhub2gateway) call
13
+ * captureEvent() directly for their domain-specific events.
14
+ */
15
+
16
+ import type {PluginApi} from '../types'
17
+ import {captureEvent} from './posthog'
18
+
19
+ export function registerAnalytics(api: PluginApi) {
20
+ // ── agent_end: track every agent turn ───────────────────────────
21
+ api.on('agent_end', (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
22
+ const trigger = typeof ctx?.trigger === 'string' ? ctx.trigger : 'unknown'
23
+ const agentId = typeof ctx?.agentId === 'string' ? ctx.agentId : undefined
24
+ const sessionKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey : undefined
25
+ const sessionId = typeof ctx?.sessionId === 'string' ? ctx.sessionId : undefined
26
+ const success = event.error == null
27
+ const durationMs = typeof event.durationMs === 'number' ? event.durationMs : undefined
28
+
29
+ captureEvent('agent.turn_completed', {
30
+ trigger,
31
+ ...(agentId ? {agent_id: agentId} : {}),
32
+ ...(sessionKey ? {session_key: sessionKey} : {}),
33
+ ...(sessionId ? {session_id: sessionId} : {}),
34
+ success,
35
+ ...(durationMs != null ? {duration_ms: durationMs} : {}),
36
+ ...(typeof event.error === 'string' ? {error: event.error} : {}),
37
+ })
38
+ })
39
+
40
+ // ── llm_output: track token usage ──────────────────────────────
41
+ api.on('llm_output', (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
42
+ const provider = typeof event.provider === 'string' ? event.provider : undefined
43
+ const model = typeof event.model === 'string' ? event.model : undefined
44
+ const usage = event.usage as Record<string, unknown> | undefined
45
+
46
+ if (!usage) return
47
+
48
+ const trigger = typeof ctx?.trigger === 'string' ? ctx.trigger : undefined
49
+ const sessionId = typeof ctx?.sessionId === 'string' ? ctx.sessionId : undefined
50
+
51
+ captureEvent('llm.response', {
52
+ ...(provider ? {provider} : {}),
53
+ ...(model ? {model} : {}),
54
+ ...(trigger ? {trigger} : {}),
55
+ ...(sessionId ? {session_id: sessionId} : {}),
56
+ ...(typeof usage.input === 'number' ? {tokens_input: usage.input} : {}),
57
+ ...(typeof usage.output === 'number' ? {tokens_output: usage.output} : {}),
58
+ ...(typeof usage.cacheRead === 'number' ? {tokens_cache_read: usage.cacheRead} : {}),
59
+ ...(typeof usage.cacheWrite === 'number' ? {tokens_cache_write: usage.cacheWrite} : {}),
60
+ ...(typeof usage.total === 'number' ? {tokens_total: usage.total} : {}),
61
+ })
62
+ })
63
+
64
+ // ── after_tool_call: track tool usage ──────────────────────────
65
+ api.on('after_tool_call', (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
66
+ const toolName = typeof event.toolName === 'string' ? event.toolName : undefined
67
+ if (!toolName) return
68
+
69
+ const sessionId = typeof ctx?.sessionId === 'string' ? ctx.sessionId : undefined
70
+ const success = event.error == null
71
+ const durationMs = typeof event.durationMs === 'number' ? event.durationMs : undefined
72
+
73
+ captureEvent('tool.called', {
74
+ tool_name: toolName,
75
+ success,
76
+ ...(sessionId ? {session_id: sessionId} : {}),
77
+ ...(durationMs != null ? {duration_ms: durationMs} : {}),
78
+ ...(typeof event.error === 'string' ? {error: event.error} : {}),
79
+ })
80
+ })
81
+
82
+ api.logger.info('analytics: registered agent_end + llm_output + after_tool_call hooks')
83
+ }
@@ -20,6 +20,7 @@ import fs from 'node:fs/promises'
20
20
  import path from 'node:path'
21
21
 
22
22
  import type {PluginApi} from '../types'
23
+ import {captureEvent} from './posthog'
23
24
 
24
25
  type JsonSchema = Record<string, unknown>
25
26
 
@@ -182,13 +183,21 @@ export function registerClawhub2gateway(api: PluginApi) {
182
183
  env,
183
184
  })
184
185
  const output = joinOutput(res.stdout, res.stderr)
186
+ const action = rpc.method.split('.').pop() ?? rpc.method
185
187
  if (res.code && res.code !== 0) {
188
+ captureEvent('clawhub.action', {action, success: false})
186
189
  respond(false, undefined, {
187
190
  code: 'command_failed',
188
191
  message: output || `clawhub exited with code ${res.code}`,
189
192
  })
190
193
  return
191
194
  }
195
+ captureEvent('clawhub.action', {
196
+ action,
197
+ success: true,
198
+ ...(params.slug ? {slug: params.slug} : {}),
199
+ ...(params.query ? {query: params.query} : {}),
200
+ })
192
201
  respond(true, {
193
202
  ok: true,
194
203
  output,
@@ -201,6 +210,12 @@ export function registerClawhub2gateway(api: PluginApi) {
201
210
  cwd: built.cwd,
202
211
  })
203
212
  } catch (err) {
213
+ const action = rpc.method.split('.').pop() ?? rpc.method
214
+ captureEvent('clawhub.action', {
215
+ action,
216
+ success: false,
217
+ error: err instanceof Error ? err.message : String(err),
218
+ })
204
219
  respond(false, undefined, {
205
220
  code: 'error',
206
221
  message: err instanceof Error ? err.message : String(err),
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import type {PluginApi} from '../types'
18
+ import {markCronDelivered, markCronDeliverySkipped} from './cron-telemetry'
18
19
  import {injectAssistantMessage, resolveSessionKey} from './inject'
19
20
  import {shouldSkipPushForMessage} from './offline-push'
20
21
 
@@ -51,6 +52,10 @@ export function registerCronDelivery(api: PluginApi) {
51
52
  const sessionKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey : undefined
52
53
  const agentId = typeof ctx?.agentId === 'string' ? ctx.agentId : undefined
53
54
 
55
+ api.logger.info(
56
+ `cron-delivery[debug]: agent_end fired sessionKey=${sessionKey ?? 'undefined'} agentId=${agentId ?? 'undefined'} trigger=${String(ctx?.trigger ?? 'undefined')}`,
57
+ )
58
+
54
59
  // Only fire for cron sessions
55
60
  if (!sessionKey?.startsWith('agent:clawly:cron:')) return
56
61
 
@@ -59,6 +64,7 @@ export function registerCronDelivery(api: PluginApi) {
59
64
  const text = getRawLastAssistantText(event.messages)
60
65
  if (text == null) {
61
66
  api.logger.info('cron-delivery: skipped (no assistant message)')
67
+ if (sessionKey) markCronDeliverySkipped(sessionKey, 'no assistant message')
62
68
  return
63
69
  }
64
70
 
@@ -66,12 +72,14 @@ export function registerCronDelivery(api: PluginApi) {
66
72
  const reason = shouldSkipPushForMessage(text)
67
73
  if (reason) {
68
74
  api.logger.info(`cron-delivery: skipped (filtered: ${reason})`)
75
+ if (sessionKey) markCronDeliverySkipped(sessionKey, `filtered: ${reason}`)
69
76
  return
70
77
  }
71
78
 
72
79
  // Resolve main session key for this agent
73
80
  if (!agentId) {
74
81
  api.logger.error('cron-delivery: skipped (no agentId on ctx)')
82
+ if (sessionKey) markCronDeliverySkipped(sessionKey, 'no agentId')
75
83
  return
76
84
  }
77
85
 
@@ -86,13 +94,16 @@ export function registerCronDelivery(api: PluginApi) {
86
94
  api,
87
95
  )
88
96
 
97
+ if (sessionKey) markCronDelivered(sessionKey)
89
98
  api.logger.info(
90
99
  `cron-delivery: injected into ${mainSessionKey} (messageId=${result.messageId})`,
91
100
  )
92
101
  } catch (err) {
93
- api.logger.error(`cron-delivery: ${err instanceof Error ? err.message : String(err)}`)
102
+ const msg = err instanceof Error ? err.message : String(err)
103
+ if (sessionKey) markCronDeliverySkipped(sessionKey, msg)
104
+ api.logger.error(`cron-delivery: ${msg}`)
94
105
  }
95
106
  })
96
107
 
97
- api.logger.info('cron-delivery: registered agent_end hook')
108
+ api.logger.info('cron-delivery: registered agent_end hook (debug-instrumented)')
98
109
  }
@@ -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
+ })