@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 +5 -0
- package/config-setup.ts +5 -0
- 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/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
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),
|
package/gateway/cron-delivery.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|