@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/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
+ })
@@ -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-role filter reasons — user-role triggers (heartbeat
81
- * prompt, memory flush, etc.) are not directly available on the event.
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
- // agentId and sessionKey live on ctx (PluginHookAgentContext), not event.
130
- const sessionKey = typeof ctx?.sessionKey === 'string' ? ctx.sessionKey : undefined
131
- const agentId = typeof ctx?.agentId === 'string' ? ctx.agentId : undefined
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
- api.logger.error(`offline-push: ${err instanceof Error ? err.message : String(err)}`)
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
+ })
@@ -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
+ }
@@ -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
  }