@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
package/gateway/index.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type {PluginApi} from '../types'
|
|
2
2
|
import {registerAgentSend} from './agent'
|
|
3
|
+
import {registerAnalytics} from './analytics'
|
|
3
4
|
import {registerClawhub2gateway} from './clawhub2gateway'
|
|
4
5
|
import {registerConfigRepair} from './config-repair'
|
|
5
6
|
import {registerCronDelivery} from './cron-delivery'
|
|
7
|
+
import {registerCronTelemetry} from './cron-telemetry'
|
|
8
|
+
import {initOtel, shutdownOtel} from './otel'
|
|
9
|
+
import {initPostHog, setOpenClawVersion, shutdownPostHog} from './posthog'
|
|
6
10
|
import {registerPairing} from './pairing'
|
|
7
11
|
import {registerMemoryBrowser} from './memory'
|
|
8
12
|
import {registerNotification} from './notification'
|
|
@@ -12,6 +16,33 @@ import {registerPresence} from './presence'
|
|
|
12
16
|
import {registerVersion} from './version'
|
|
13
17
|
|
|
14
18
|
export function registerGateway(api: PluginApi) {
|
|
19
|
+
// Initialize OTel logs provider (no-op if plugin telemetry config is missing)
|
|
20
|
+
const otelEnabled = initOtel(api.pluginConfig)
|
|
21
|
+
if (otelEnabled) {
|
|
22
|
+
api.logger.info('otel: LoggerProvider initialized (OTLP/HTTP)')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Initialize PostHog analytics (no-op if plugin telemetry config is missing)
|
|
26
|
+
const posthogEnabled = initPostHog(api.pluginConfig)
|
|
27
|
+
if (posthogEnabled) {
|
|
28
|
+
const openclawVersion = api.config?.meta?.lastTouchedVersion
|
|
29
|
+
if (openclawVersion) setOpenClawVersion(openclawVersion)
|
|
30
|
+
api.logger.info('posthog: client initialized')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (otelEnabled || posthogEnabled) {
|
|
34
|
+
api.on('gateway_stop', async () => {
|
|
35
|
+
if (otelEnabled) {
|
|
36
|
+
await shutdownOtel()
|
|
37
|
+
api.logger.info('otel: LoggerProvider shut down')
|
|
38
|
+
}
|
|
39
|
+
if (posthogEnabled) {
|
|
40
|
+
await shutdownPostHog()
|
|
41
|
+
api.logger.info('posthog: client shut down')
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
15
46
|
registerPresence(api)
|
|
16
47
|
registerNotification(api)
|
|
17
48
|
registerAgentSend(api)
|
|
@@ -20,6 +51,8 @@ export function registerGateway(api: PluginApi) {
|
|
|
20
51
|
registerPlugins(api)
|
|
21
52
|
registerOfflinePush(api)
|
|
22
53
|
registerCronDelivery(api)
|
|
54
|
+
registerCronTelemetry(api)
|
|
55
|
+
registerAnalytics(api)
|
|
23
56
|
registerConfigRepair(api)
|
|
24
57
|
registerPairing(api)
|
|
25
58
|
registerVersion(api)
|
|
@@ -3,6 +3,8 @@ import type {PluginApi} from '../types'
|
|
|
3
3
|
import {
|
|
4
4
|
getLastAssistantPreview,
|
|
5
5
|
getLastAssistantText,
|
|
6
|
+
getTriggeringUserText,
|
|
7
|
+
isHeartbeatTriggered,
|
|
6
8
|
registerOfflinePush,
|
|
7
9
|
shouldSkipPushForMessage,
|
|
8
10
|
} from './offline-push'
|
|
@@ -515,3 +517,210 @@ describe('offline-push with filtered messages', () => {
|
|
|
515
517
|
})
|
|
516
518
|
})
|
|
517
519
|
})
|
|
520
|
+
|
|
521
|
+
// ── getTriggeringUserText unit tests ─────────────────────────────
|
|
522
|
+
|
|
523
|
+
describe('getTriggeringUserText', () => {
|
|
524
|
+
test('returns null for non-array input', () => {
|
|
525
|
+
expect(getTriggeringUserText(undefined)).toBeNull()
|
|
526
|
+
expect(getTriggeringUserText(null)).toBeNull()
|
|
527
|
+
expect(getTriggeringUserText('string')).toBeNull()
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
test('extracts user text preceding the last assistant message', () => {
|
|
531
|
+
const messages = [
|
|
532
|
+
{role: 'user', content: 'Hello'},
|
|
533
|
+
{role: 'assistant', content: 'Hi there!'},
|
|
534
|
+
]
|
|
535
|
+
expect(getTriggeringUserText(messages)).toBe('Hello')
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
test('returns the user message just before the last assistant, not earlier ones', () => {
|
|
539
|
+
const messages = [
|
|
540
|
+
{role: 'user', content: 'First question'},
|
|
541
|
+
{role: 'assistant', content: 'First reply'},
|
|
542
|
+
{role: 'user', content: 'Second question'},
|
|
543
|
+
{role: 'assistant', content: 'Second reply'},
|
|
544
|
+
]
|
|
545
|
+
expect(getTriggeringUserText(messages)).toBe('Second question')
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
test('skips system messages between assistant and user', () => {
|
|
549
|
+
const messages = [
|
|
550
|
+
{role: 'user', content: 'Trigger text'},
|
|
551
|
+
{role: 'system', content: 'System instruction'},
|
|
552
|
+
{role: 'assistant', content: 'Response'},
|
|
553
|
+
]
|
|
554
|
+
expect(getTriggeringUserText(messages)).toBe('Trigger text')
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
test('returns null when no user message precedes assistant', () => {
|
|
558
|
+
const messages = [{role: 'assistant', content: 'Unprompted response'}]
|
|
559
|
+
expect(getTriggeringUserText(messages)).toBeNull()
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
test('returns null when no assistant message exists', () => {
|
|
563
|
+
const messages = [{role: 'user', content: 'Hello'}]
|
|
564
|
+
expect(getTriggeringUserText(messages)).toBeNull()
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
test('handles array content format for user message', () => {
|
|
568
|
+
const messages = [
|
|
569
|
+
{
|
|
570
|
+
role: 'user',
|
|
571
|
+
content: [
|
|
572
|
+
{type: 'text', text: 'Read HEARTBEAT.md'},
|
|
573
|
+
{type: 'text', text: ' and follow instructions'},
|
|
574
|
+
],
|
|
575
|
+
},
|
|
576
|
+
{role: 'assistant', content: 'HEARTBEAT_OK'},
|
|
577
|
+
]
|
|
578
|
+
expect(getTriggeringUserText(messages)).toBe('Read HEARTBEAT.md and follow instructions')
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
test('returns null for empty messages array', () => {
|
|
582
|
+
expect(getTriggeringUserText([])).toBeNull()
|
|
583
|
+
})
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
// ── isHeartbeatTriggered unit tests ──────────────────────────────
|
|
587
|
+
|
|
588
|
+
describe('isHeartbeatTriggered', () => {
|
|
589
|
+
test('returns true for heartbeat prompt', () => {
|
|
590
|
+
const messages = [
|
|
591
|
+
{role: 'user', content: 'Read HEARTBEAT.md and follow the instructions inside.'},
|
|
592
|
+
{role: 'assistant', content: 'HEARTBEAT_OK'},
|
|
593
|
+
]
|
|
594
|
+
expect(isHeartbeatTriggered(messages)).toBe(true)
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
test('returns true for verbose heartbeat response', () => {
|
|
598
|
+
const messages = [
|
|
599
|
+
{role: 'user', content: 'Read HEARTBEAT.md and follow the instructions inside.'},
|
|
600
|
+
{
|
|
601
|
+
role: 'assistant',
|
|
602
|
+
content: 'Checked your inbox, nothing new. All clear! HEARTBEAT_OK',
|
|
603
|
+
},
|
|
604
|
+
]
|
|
605
|
+
expect(isHeartbeatTriggered(messages)).toBe(true)
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
test('returns true for heartbeat response without HEARTBEAT_OK', () => {
|
|
609
|
+
const messages = [
|
|
610
|
+
{role: 'user', content: 'Read HEARTBEAT.md and follow the instructions inside.'},
|
|
611
|
+
{
|
|
612
|
+
role: 'assistant',
|
|
613
|
+
content: 'Nothing to report right now. Everything looks quiet.',
|
|
614
|
+
},
|
|
615
|
+
]
|
|
616
|
+
expect(isHeartbeatTriggered(messages)).toBe(true)
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
test('returns false for normal user messages', () => {
|
|
620
|
+
const messages = [
|
|
621
|
+
{role: 'user', content: 'What is the weather today?'},
|
|
622
|
+
{role: 'assistant', content: 'Let me check...'},
|
|
623
|
+
]
|
|
624
|
+
expect(isHeartbeatTriggered(messages)).toBe(false)
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
test('returns false for no messages', () => {
|
|
628
|
+
expect(isHeartbeatTriggered([])).toBe(false)
|
|
629
|
+
expect(isHeartbeatTriggered(undefined)).toBe(false)
|
|
630
|
+
})
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
// ── Heartbeat-triggered integration tests ────────────────────────
|
|
634
|
+
|
|
635
|
+
describe('offline-push with heartbeat-triggered turns', () => {
|
|
636
|
+
test('skips push for verbose heartbeat response', async () => {
|
|
637
|
+
const {api, logs, handlers} = createMockApi()
|
|
638
|
+
registerOfflinePush(api)
|
|
639
|
+
|
|
640
|
+
await handlers.get('agent_end')!(
|
|
641
|
+
{
|
|
642
|
+
messages: [
|
|
643
|
+
{role: 'user', content: 'Read HEARTBEAT.md and follow the instructions inside.'},
|
|
644
|
+
{
|
|
645
|
+
role: 'assistant',
|
|
646
|
+
content: 'Checked your inbox, nothing new. All clear! HEARTBEAT_OK',
|
|
647
|
+
},
|
|
648
|
+
],
|
|
649
|
+
},
|
|
650
|
+
{sessionKey: 'agent:clawly:main'},
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
expect(logs).toContainEqual({
|
|
654
|
+
level: 'info',
|
|
655
|
+
msg: 'offline-push: skipped (heartbeat-triggered turn)',
|
|
656
|
+
})
|
|
657
|
+
expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
test('skips push for heartbeat response without HEARTBEAT_OK sentinel', async () => {
|
|
661
|
+
const {api, logs, handlers} = createMockApi()
|
|
662
|
+
registerOfflinePush(api)
|
|
663
|
+
|
|
664
|
+
await handlers.get('agent_end')!(
|
|
665
|
+
{
|
|
666
|
+
messages: [
|
|
667
|
+
{role: 'user', content: 'Read HEARTBEAT.md and follow the instructions inside.'},
|
|
668
|
+
{
|
|
669
|
+
role: 'assistant',
|
|
670
|
+
content: 'Nothing to report. Everything looks quiet on all fronts.',
|
|
671
|
+
},
|
|
672
|
+
],
|
|
673
|
+
},
|
|
674
|
+
{sessionKey: 'agent:clawly:main'},
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
expect(logs).toContainEqual({
|
|
678
|
+
level: 'info',
|
|
679
|
+
msg: 'offline-push: skipped (heartbeat-triggered turn)',
|
|
680
|
+
})
|
|
681
|
+
expect(logs.filter((l) => l.msg.includes('notified'))).toHaveLength(0)
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
test('bare HEARTBEAT_OK is caught by existing filter before heartbeat-context check', async () => {
|
|
685
|
+
const {api, logs, handlers} = createMockApi()
|
|
686
|
+
registerOfflinePush(api)
|
|
687
|
+
|
|
688
|
+
await handlers.get('agent_end')!(
|
|
689
|
+
{
|
|
690
|
+
messages: [
|
|
691
|
+
{role: 'user', content: 'Read HEARTBEAT.md and follow the instructions inside.'},
|
|
692
|
+
{role: 'assistant', content: 'HEARTBEAT_OK'},
|
|
693
|
+
],
|
|
694
|
+
},
|
|
695
|
+
{sessionKey: 'agent:clawly:main'},
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
// Should be caught by shouldSkipPushForMessage (heartbeat ack), not the trigger check
|
|
699
|
+
expect(logs).toContainEqual({
|
|
700
|
+
level: 'info',
|
|
701
|
+
msg: expect.stringContaining('skipped (filtered: heartbeat ack)'),
|
|
702
|
+
})
|
|
703
|
+
expect(logs.filter((l) => l.msg.includes('heartbeat-triggered turn'))).toHaveLength(0)
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
test('non-heartbeat turn sends push normally (no regression)', async () => {
|
|
707
|
+
const {api, logs, handlers} = createMockApi()
|
|
708
|
+
registerOfflinePush(api)
|
|
709
|
+
|
|
710
|
+
await handlers.get('agent_end')!(
|
|
711
|
+
{
|
|
712
|
+
messages: [
|
|
713
|
+
{role: 'user', content: 'What is the weather like today?'},
|
|
714
|
+
{role: 'assistant', content: 'It is sunny and 72°F!'},
|
|
715
|
+
],
|
|
716
|
+
},
|
|
717
|
+
{sessionKey: 'agent:clawly:main'},
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
expect(logs).toContainEqual({
|
|
721
|
+
level: 'info',
|
|
722
|
+
msg: expect.stringContaining('notified (session=agent:clawly:main)'),
|
|
723
|
+
})
|
|
724
|
+
expect(logs.filter((l) => l.msg.includes('heartbeat-triggered'))).toHaveLength(0)
|
|
725
|
+
})
|
|
726
|
+
})
|
package/gateway/offline-push.ts
CHANGED
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type {PluginApi} from '../types'
|
|
13
|
+
import {markCronPushSent, markCronPushSkipped} from './cron-telemetry'
|
|
13
14
|
import {
|
|
14
15
|
decrementBadgeCount,
|
|
15
16
|
getPushToken,
|
|
16
17
|
incrementBadgeCount,
|
|
17
18
|
sendPushNotification,
|
|
18
19
|
} from './notification'
|
|
20
|
+
import {captureEvent} from './posthog'
|
|
19
21
|
import {isClientOnline} from './presence'
|
|
20
22
|
|
|
21
23
|
/** Strip [[type:value]], [[word]], and MEDIA:xxx placeholders from text (canonical: apps/mobile/lib/stripPlaceholders.ts). */
|
|
@@ -70,6 +72,54 @@ export function getLastAssistantPreview(messages: unknown, maxLen = 140): string
|
|
|
70
72
|
return `${text.slice(0, maxLen)}…`
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
// ── Trigger detection: inspect what caused the agent turn ──
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Walk backwards from the last assistant message to find the preceding
|
|
79
|
+
* user message text. Symmetric to `getLastAssistantText` but for the
|
|
80
|
+
* triggering user turn.
|
|
81
|
+
*/
|
|
82
|
+
export function getTriggeringUserText(messages: unknown): string | null {
|
|
83
|
+
if (!Array.isArray(messages)) return null
|
|
84
|
+
|
|
85
|
+
// Find the last assistant message index, then look backwards for user
|
|
86
|
+
let foundAssistant = false
|
|
87
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
88
|
+
const msg = messages[i]
|
|
89
|
+
if (typeof msg !== 'object' || msg === null) continue
|
|
90
|
+
const role = (msg as any).role
|
|
91
|
+
|
|
92
|
+
if (!foundAssistant) {
|
|
93
|
+
if (role === 'assistant') foundAssistant = true
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Skip system messages between assistant and user
|
|
98
|
+
if (role !== 'user') continue
|
|
99
|
+
|
|
100
|
+
const content = (msg as any).content
|
|
101
|
+
if (typeof content === 'string') return content
|
|
102
|
+
if (Array.isArray(content)) {
|
|
103
|
+
return content
|
|
104
|
+
.filter((p: any) => typeof p === 'object' && p !== null && p.type === 'text')
|
|
105
|
+
.map((p: any) => p.text)
|
|
106
|
+
.join('')
|
|
107
|
+
}
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Returns true if the agent turn was triggered by a heartbeat prompt.
|
|
116
|
+
* Mirrors the mobile filter: `text.startsWith('Read HEARTBEAT.md')`.
|
|
117
|
+
*/
|
|
118
|
+
export function isHeartbeatTriggered(messages: unknown): boolean {
|
|
119
|
+
const text = getTriggeringUserText(messages)
|
|
120
|
+
return text != null && text.startsWith('Read HEARTBEAT.md')
|
|
121
|
+
}
|
|
122
|
+
|
|
73
123
|
// ── Push-filter: skip push for messages the mobile UI would hide ──
|
|
74
124
|
|
|
75
125
|
/**
|
|
@@ -77,8 +127,8 @@ export function getLastAssistantPreview(messages: unknown, maxLen = 140): string
|
|
|
77
127
|
* UI would filter out via shouldFilterMessage(). If true, skip the push
|
|
78
128
|
* so the user isn't woken for nothing.
|
|
79
129
|
*
|
|
80
|
-
* Only covers assistant-
|
|
81
|
-
*
|
|
130
|
+
* Only covers assistant-text filter reasons. Trigger-level filtering
|
|
131
|
+
* (e.g. heartbeat prompt detection) is handled by `isHeartbeatTriggered`.
|
|
82
132
|
*/
|
|
83
133
|
export function shouldSkipPushForMessage(text: string): string | null {
|
|
84
134
|
const trimmed = text.trim()
|
|
@@ -106,10 +156,25 @@ export function shouldSkipPushForMessage(text: string): string | null {
|
|
|
106
156
|
|
|
107
157
|
export function registerOfflinePush(api: PluginApi) {
|
|
108
158
|
api.on('agent_end', async (event: Record<string, unknown>, ctx?: Record<string, unknown>) => {
|
|
159
|
+
// Read sessionKey early so we can report skip reasons for cron sessions.
|
|
160
|
+
const sessionKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey : undefined
|
|
161
|
+
const agentId = typeof ctx?.agentId === 'string' ? ctx.agentId : undefined
|
|
162
|
+
const isCron = sessionKey?.startsWith('agent:clawly:cron:') ?? false
|
|
163
|
+
|
|
164
|
+
api.logger.info(
|
|
165
|
+
`offline-push[debug]: agent_end fired sessionKey=${sessionKey ?? 'undefined'} trigger=${String(ctx?.trigger ?? 'undefined')} isCron=${isCron}`,
|
|
166
|
+
)
|
|
167
|
+
|
|
109
168
|
try {
|
|
110
169
|
// Skip if client is still connected — they got the response in real-time.
|
|
111
170
|
const online = await isClientOnline()
|
|
112
171
|
if (online) {
|
|
172
|
+
if (isCron) markCronPushSkipped(sessionKey!, 'client online', true)
|
|
173
|
+
captureEvent('push.skipped', {
|
|
174
|
+
reason: 'client_online',
|
|
175
|
+
is_cron: isCron,
|
|
176
|
+
...(sessionKey ? {session_key: sessionKey} : {}),
|
|
177
|
+
})
|
|
113
178
|
api.logger.info('offline-push: skipped (client online)')
|
|
114
179
|
return
|
|
115
180
|
}
|
|
@@ -121,22 +186,33 @@ export function registerOfflinePush(api: PluginApi) {
|
|
|
121
186
|
if (fullText != null) {
|
|
122
187
|
const reason = shouldSkipPushForMessage(fullText)
|
|
123
188
|
if (reason) {
|
|
189
|
+
if (isCron) markCronPushSkipped(sessionKey!, `filtered: ${reason}`, false)
|
|
190
|
+
captureEvent('push.skipped', {
|
|
191
|
+
reason: `filtered_${reason}`,
|
|
192
|
+
is_cron: isCron,
|
|
193
|
+
...(sessionKey ? {session_key: sessionKey} : {}),
|
|
194
|
+
})
|
|
124
195
|
api.logger.info(`offline-push: skipped (filtered: ${reason})`)
|
|
125
196
|
return
|
|
126
197
|
}
|
|
127
198
|
}
|
|
128
199
|
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
200
|
+
// Heartbeat-triggered turns are silent by default — agent uses
|
|
201
|
+
// clawly_send_app_push tool for genuinely urgent findings.
|
|
202
|
+
if (isHeartbeatTriggered(event.messages)) {
|
|
203
|
+
if (isCron) markCronPushSkipped(sessionKey!, 'heartbeat-triggered', false)
|
|
204
|
+
captureEvent('push.skipped', {
|
|
205
|
+
reason: 'heartbeat_triggered',
|
|
206
|
+
is_cron: isCron,
|
|
207
|
+
...(sessionKey ? {session_key: sessionKey} : {}),
|
|
208
|
+
})
|
|
209
|
+
api.logger.info('offline-push: skipped (heartbeat-triggered turn)')
|
|
210
|
+
return
|
|
211
|
+
}
|
|
132
212
|
|
|
133
213
|
// Only send push for the main clawly mobile session and cron sessions —
|
|
134
214
|
// skip channel sessions (telegram, slack, discord, etc.) which have their own delivery.
|
|
135
|
-
if (
|
|
136
|
-
sessionKey !== undefined &&
|
|
137
|
-
sessionKey !== 'agent:clawly:main' &&
|
|
138
|
-
!sessionKey.startsWith('agent:clawly:cron:')
|
|
139
|
-
) {
|
|
215
|
+
if (sessionKey !== undefined && sessionKey !== 'agent:clawly:main' && !isCron) {
|
|
140
216
|
api.logger.info(`offline-push: skipped (non-main session: ${sessionKey})`)
|
|
141
217
|
return
|
|
142
218
|
}
|
|
@@ -147,6 +223,12 @@ export function registerOfflinePush(api: PluginApi) {
|
|
|
147
223
|
const body = preview || 'Your response is ready'
|
|
148
224
|
|
|
149
225
|
if (!getPushToken()) {
|
|
226
|
+
if (isCron) markCronPushSkipped(sessionKey!, 'no push token', false)
|
|
227
|
+
captureEvent('push.skipped', {
|
|
228
|
+
reason: 'no_push_token',
|
|
229
|
+
is_cron: isCron,
|
|
230
|
+
...(sessionKey ? {session_key: sessionKey} : {}),
|
|
231
|
+
})
|
|
150
232
|
api.logger.warn('offline-push: skipped (no push token)')
|
|
151
233
|
return
|
|
152
234
|
}
|
|
@@ -166,14 +248,27 @@ export function registerOfflinePush(api: PluginApi) {
|
|
|
166
248
|
)
|
|
167
249
|
|
|
168
250
|
if (sent) {
|
|
251
|
+
if (isCron) markCronPushSent(sessionKey!)
|
|
252
|
+
captureEvent('push.sent', {
|
|
253
|
+
is_cron: isCron,
|
|
254
|
+
...(sessionKey ? {session_key: sessionKey} : {}),
|
|
255
|
+
})
|
|
169
256
|
api.logger.info(`offline-push: notified (session=${sessionKey ?? 'unknown'})`)
|
|
170
257
|
} else {
|
|
171
258
|
decrementBadgeCount()
|
|
259
|
+
if (isCron) markCronPushSkipped(sessionKey!, 'send failed', false)
|
|
260
|
+
captureEvent('push.skipped', {
|
|
261
|
+
reason: 'send_failed',
|
|
262
|
+
is_cron: isCron,
|
|
263
|
+
...(sessionKey ? {session_key: sessionKey} : {}),
|
|
264
|
+
})
|
|
172
265
|
}
|
|
173
266
|
} catch (err) {
|
|
174
|
-
|
|
267
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
268
|
+
if (isCron) markCronPushSkipped(sessionKey!, msg)
|
|
269
|
+
api.logger.error(`offline-push: ${msg}`)
|
|
175
270
|
}
|
|
176
271
|
})
|
|
177
272
|
|
|
178
|
-
api.logger.info('offline-push: registered agent_end hook')
|
|
273
|
+
api.logger.info('offline-push: registered agent_end hook (debug-instrumented)')
|
|
179
274
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, mock, test} from 'bun:test'
|
|
2
|
+
|
|
3
|
+
let exporterOptions: Record<string, unknown> | null = null
|
|
4
|
+
let loggerName: string | null = null
|
|
5
|
+
let shutdownCount = 0
|
|
6
|
+
const fakeLogger = {emit: () => {}}
|
|
7
|
+
|
|
8
|
+
mock.module('@opentelemetry/exporter-logs-otlp-http', () => ({
|
|
9
|
+
OTLPLogExporter: class OTLPLogExporter {
|
|
10
|
+
constructor(opts: Record<string, unknown>) {
|
|
11
|
+
exporterOptions = opts
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
mock.module('@opentelemetry/resources', () => ({
|
|
17
|
+
Resource: class Resource {
|
|
18
|
+
constructor(_attrs: Record<string, unknown>) {}
|
|
19
|
+
},
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
mock.module('@opentelemetry/sdk-logs', () => ({
|
|
23
|
+
BatchLogRecordProcessor: class BatchLogRecordProcessor {
|
|
24
|
+
constructor(_exporter: unknown) {}
|
|
25
|
+
},
|
|
26
|
+
LoggerProvider: class LoggerProvider {
|
|
27
|
+
addLogRecordProcessor(_processor: unknown) {}
|
|
28
|
+
getLogger(name: string) {
|
|
29
|
+
loggerName = name
|
|
30
|
+
return fakeLogger
|
|
31
|
+
}
|
|
32
|
+
async shutdown() {
|
|
33
|
+
shutdownCount++
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
const {getOtelLogger, initOtel, shutdownOtel} = await import('./otel')
|
|
39
|
+
|
|
40
|
+
describe('initOtel', () => {
|
|
41
|
+
beforeEach(async () => {
|
|
42
|
+
await shutdownOtel()
|
|
43
|
+
exporterOptions = null
|
|
44
|
+
loggerName = null
|
|
45
|
+
shutdownCount = 0
|
|
46
|
+
delete process.env.PLUGIN_OTEL_TOKEN
|
|
47
|
+
delete process.env.PLUGIN_OTEL_DATASET
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterEach(async () => {
|
|
51
|
+
await shutdownOtel()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('prefers pluginConfig over legacy env vars', () => {
|
|
55
|
+
process.env.PLUGIN_OTEL_TOKEN = 'legacy-token'
|
|
56
|
+
process.env.PLUGIN_OTEL_DATASET = 'legacy-dataset'
|
|
57
|
+
|
|
58
|
+
const enabled = initOtel({
|
|
59
|
+
otelToken: 'cfg-token',
|
|
60
|
+
otelDataset: 'cfg-dataset',
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
expect(enabled).toBe(true)
|
|
64
|
+
expect(exporterOptions).toEqual({
|
|
65
|
+
url: 'https://api.axiom.co',
|
|
66
|
+
headers: {
|
|
67
|
+
Authorization: 'Bearer cfg-token',
|
|
68
|
+
'X-Axiom-Dataset': 'cfg-dataset',
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
expect(getOtelLogger()).toBeTruthy()
|
|
72
|
+
expect(loggerName).toBe('cron-telemetry')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('falls back to legacy env vars', () => {
|
|
76
|
+
process.env.PLUGIN_OTEL_TOKEN = 'legacy-token'
|
|
77
|
+
process.env.PLUGIN_OTEL_DATASET = 'legacy-dataset'
|
|
78
|
+
|
|
79
|
+
expect(initOtel()).toBe(true)
|
|
80
|
+
expect((exporterOptions?.headers as Record<string, string>)['X-Axiom-Dataset']).toBe(
|
|
81
|
+
'legacy-dataset',
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('returns false when no telemetry config is present', () => {
|
|
86
|
+
expect(initOtel()).toBe(false)
|
|
87
|
+
})
|
|
88
|
+
})
|
package/gateway/otel.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenTelemetry Logs provider for plugin telemetry.
|
|
3
|
+
*
|
|
4
|
+
* Reads telemetry config provisioned by Fleet via pluginConfig:
|
|
5
|
+
*
|
|
6
|
+
* otelToken=<Axiom API token>
|
|
7
|
+
* otelDataset=<Axiom dataset name>
|
|
8
|
+
*
|
|
9
|
+
* Falls back to legacy PLUGIN_OTEL_* env vars for backward compatibility and
|
|
10
|
+
* manual debug sessions.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {Logger} from '@opentelemetry/api-logs'
|
|
14
|
+
import {OTLPLogExporter} from '@opentelemetry/exporter-logs-otlp-http'
|
|
15
|
+
import {Resource} from '@opentelemetry/resources'
|
|
16
|
+
import {BatchLogRecordProcessor, LoggerProvider} from '@opentelemetry/sdk-logs'
|
|
17
|
+
import {readTelemetryPluginConfig} from './telemetry-config'
|
|
18
|
+
|
|
19
|
+
let provider: LoggerProvider | null = null
|
|
20
|
+
let logger: Logger | null = null
|
|
21
|
+
|
|
22
|
+
export function initOtel(
|
|
23
|
+
pluginConfig?: Record<string, unknown>,
|
|
24
|
+
serviceName = 'clawly-plugins',
|
|
25
|
+
): boolean {
|
|
26
|
+
const cfg = readTelemetryPluginConfig(pluginConfig)
|
|
27
|
+
const token = cfg.otelToken ?? process.env.PLUGIN_OTEL_TOKEN
|
|
28
|
+
const dataset = cfg.otelDataset ?? process.env.PLUGIN_OTEL_DATASET
|
|
29
|
+
if (!token || !dataset) return false
|
|
30
|
+
if (provider) return true
|
|
31
|
+
|
|
32
|
+
const resource = new Resource({'service.name': serviceName})
|
|
33
|
+
const exporter = new OTLPLogExporter({
|
|
34
|
+
url: 'https://api.axiom.co',
|
|
35
|
+
headers: {
|
|
36
|
+
Authorization: `Bearer ${token}`,
|
|
37
|
+
'X-Axiom-Dataset': dataset,
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
provider = new LoggerProvider({resource})
|
|
42
|
+
provider.addLogRecordProcessor(new BatchLogRecordProcessor(exporter))
|
|
43
|
+
logger = provider.getLogger('cron-telemetry')
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getOtelLogger(): Logger | null {
|
|
48
|
+
return logger
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function shutdownOtel(): Promise<void> {
|
|
52
|
+
if (provider) {
|
|
53
|
+
await provider.shutdown()
|
|
54
|
+
provider = null
|
|
55
|
+
logger = null
|
|
56
|
+
}
|
|
57
|
+
}
|
package/gateway/plugins.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {$} from 'zx'
|
|
|
12
12
|
|
|
13
13
|
import type {PluginApi} from '../types'
|
|
14
14
|
import {LruCache} from '../lib/lruCache'
|
|
15
|
+
import {captureEvent} from './posthog'
|
|
15
16
|
import {isUpdateAvailable} from '../lib/semver'
|
|
16
17
|
import {stripCliLogs} from '../lib/stripCliLogs'
|
|
17
18
|
|
|
@@ -311,9 +312,11 @@ export function registerPlugins(api: PluginApi) {
|
|
|
311
312
|
}
|
|
312
313
|
}
|
|
313
314
|
|
|
315
|
+
captureEvent('plugin.updated', {plugin_id: pluginId, strategy, success: true, restarted})
|
|
314
316
|
respond(true, {ok: true, strategy, output, restarted})
|
|
315
317
|
} catch (err) {
|
|
316
318
|
const msg = err instanceof Error ? err.message : String(err)
|
|
319
|
+
captureEvent('plugin.updated', {plugin_id: pluginId, strategy, success: false, error: msg})
|
|
317
320
|
api.logger.error(`plugins: update failed — ${msg}`)
|
|
318
321
|
respond(false, {ok: false, strategy, error: msg})
|
|
319
322
|
}
|