@clinebot/core 0.0.5 → 0.0.6
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/dist/index.d.ts +4 -1
- package/dist/index.node.d.ts +1 -0
- package/dist/index.node.js +134 -107
- package/dist/runtime/session-runtime.d.ts +3 -1
- package/dist/session/default-session-manager.d.ts +4 -0
- package/dist/session/session-host.d.ts +2 -0
- package/dist/session/session-manager.d.ts +1 -0
- package/dist/telemetry/ITelemetryAdapter.d.ts +54 -0
- package/dist/telemetry/LoggerTelemetryAdapter.d.ts +21 -0
- package/dist/telemetry/OpenTelemetryAdapter.d.ts +43 -0
- package/dist/telemetry/OpenTelemetryProvider.d.ts +41 -0
- package/dist/telemetry/TelemetryService.d.ts +34 -0
- package/dist/telemetry/opentelemetry.d.ts +3 -0
- package/dist/telemetry/opentelemetry.js +27 -0
- package/dist/tools/schemas.d.ts +6 -0
- package/dist/types/config.d.ts +2 -1
- package/package.json +16 -3
- package/src/agents/hooks-config-loader.ts +19 -1
- package/src/index.node.ts +3 -0
- package/src/index.ts +16 -0
- package/src/runtime/hook-file-hooks.test.ts +47 -0
- package/src/runtime/hook-file-hooks.ts +3 -0
- package/src/runtime/runtime-builder.test.ts +20 -0
- package/src/runtime/runtime-builder.ts +1 -0
- package/src/runtime/session-runtime.ts +3 -1
- package/src/session/default-session-manager.test.ts +72 -0
- package/src/session/default-session-manager.ts +59 -1
- package/src/session/session-host.ts +6 -1
- package/src/session/session-manager.ts +1 -0
- package/src/telemetry/ITelemetryAdapter.ts +94 -0
- package/src/telemetry/LoggerTelemetryAdapter.test.ts +42 -0
- package/src/telemetry/LoggerTelemetryAdapter.ts +114 -0
- package/src/telemetry/OpenTelemetryAdapter.test.ts +157 -0
- package/src/telemetry/OpenTelemetryAdapter.ts +348 -0
- package/src/telemetry/OpenTelemetryProvider.test.ts +113 -0
- package/src/telemetry/OpenTelemetryProvider.ts +322 -0
- package/src/telemetry/TelemetryService.test.ts +134 -0
- package/src/telemetry/TelemetryService.ts +141 -0
- package/src/telemetry/opentelemetry.ts +20 -0
- package/src/tools/definitions.ts +35 -28
- package/src/tools/schemas.ts +9 -0
- package/src/types/config.ts +2 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { BasicLogger } from "@clinebot/shared";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
createConfiguredTelemetryService,
|
|
5
|
+
createOpenTelemetryTelemetryService,
|
|
6
|
+
OpenTelemetryProvider,
|
|
7
|
+
} from "./OpenTelemetryProvider";
|
|
8
|
+
import { TelemetryService } from "./TelemetryService";
|
|
9
|
+
|
|
10
|
+
describe("createOpenTelemetryTelemetryService", () => {
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.restoreAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("logs a provider creation event during bootstrap", async () => {
|
|
16
|
+
const captureRequired = vi
|
|
17
|
+
.spyOn(TelemetryService.prototype, "captureRequired")
|
|
18
|
+
.mockImplementation(() => {});
|
|
19
|
+
|
|
20
|
+
const { provider } = createOpenTelemetryTelemetryService({
|
|
21
|
+
metadata: {
|
|
22
|
+
extension_version: "1.2.3",
|
|
23
|
+
cline_type: "cli",
|
|
24
|
+
platform: "terminal",
|
|
25
|
+
platform_version: process.version,
|
|
26
|
+
os_type: process.platform,
|
|
27
|
+
os_version: "unknown",
|
|
28
|
+
},
|
|
29
|
+
enabled: true,
|
|
30
|
+
logsExporter: "console",
|
|
31
|
+
metricsExporter: "otlp",
|
|
32
|
+
otlpProtocol: "http/json",
|
|
33
|
+
otlpEndpoint: "http://localhost:4318",
|
|
34
|
+
serviceName: "cline-cli",
|
|
35
|
+
serviceVersion: "1.2.3",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(captureRequired).toHaveBeenCalledWith(
|
|
39
|
+
"telemetry.provider_created",
|
|
40
|
+
expect.objectContaining({
|
|
41
|
+
provider: "opentelemetry",
|
|
42
|
+
enabled: true,
|
|
43
|
+
logsExporter: "console",
|
|
44
|
+
metricsExporter: "otlp",
|
|
45
|
+
otlpProtocol: "http/json",
|
|
46
|
+
hasOtlpEndpoint: true,
|
|
47
|
+
serviceName: "cline-cli",
|
|
48
|
+
serviceVersion: "1.2.3",
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
await provider.dispose();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("does not create an OTEL provider when disabled", () => {
|
|
56
|
+
const providerSpy = vi.spyOn(
|
|
57
|
+
OpenTelemetryProvider.prototype,
|
|
58
|
+
"createTelemetryService",
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const { telemetry, provider } = createConfiguredTelemetryService({
|
|
62
|
+
metadata: {
|
|
63
|
+
extension_version: "1.2.3",
|
|
64
|
+
cline_type: "cli",
|
|
65
|
+
platform: "terminal",
|
|
66
|
+
platform_version: process.version,
|
|
67
|
+
os_type: process.platform,
|
|
68
|
+
os_version: "unknown",
|
|
69
|
+
},
|
|
70
|
+
enabled: false,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(provider).toBeUndefined();
|
|
74
|
+
expect(providerSpy).not.toHaveBeenCalled();
|
|
75
|
+
expect(telemetry).toBeInstanceOf(TelemetryService);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("attaches the logger adapter when creating configured telemetry", () => {
|
|
79
|
+
const logger: BasicLogger = {
|
|
80
|
+
debug: vi.fn(),
|
|
81
|
+
info: vi.fn(),
|
|
82
|
+
warn: vi.fn(),
|
|
83
|
+
error: vi.fn(),
|
|
84
|
+
};
|
|
85
|
+
const { telemetry, provider } = createConfiguredTelemetryService({
|
|
86
|
+
metadata: {
|
|
87
|
+
extension_version: "1.2.3",
|
|
88
|
+
cline_type: "cli",
|
|
89
|
+
platform: "terminal",
|
|
90
|
+
platform_version: process.version,
|
|
91
|
+
os_type: process.platform,
|
|
92
|
+
os_version: "unknown",
|
|
93
|
+
},
|
|
94
|
+
enabled: true,
|
|
95
|
+
logsExporter: "console",
|
|
96
|
+
logger,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
telemetry.capture({
|
|
100
|
+
event: "session.started",
|
|
101
|
+
properties: { sessionId: "session-1" },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
105
|
+
"telemetry.event",
|
|
106
|
+
expect.objectContaining({
|
|
107
|
+
event: "session.started",
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return provider?.dispose();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BasicLogger,
|
|
3
|
+
ITelemetryService,
|
|
4
|
+
OpenTelemetryClientConfig,
|
|
5
|
+
TelemetryMetadata,
|
|
6
|
+
} from "@clinebot/shared";
|
|
7
|
+
import { metrics } from "@opentelemetry/api";
|
|
8
|
+
import { logs } from "@opentelemetry/api-logs";
|
|
9
|
+
import { OTLPLogExporter as OTLPLogExporterHttp } from "@opentelemetry/exporter-logs-otlp-http";
|
|
10
|
+
import { OTLPMetricExporter as OTLPMetricExporterHttp } from "@opentelemetry/exporter-metrics-otlp-http";
|
|
11
|
+
import { Resource } from "@opentelemetry/resources";
|
|
12
|
+
import {
|
|
13
|
+
BatchLogRecordProcessor,
|
|
14
|
+
ConsoleLogRecordExporter,
|
|
15
|
+
LoggerProvider,
|
|
16
|
+
type LogRecordExporter,
|
|
17
|
+
} from "@opentelemetry/sdk-logs";
|
|
18
|
+
import {
|
|
19
|
+
ConsoleMetricExporter,
|
|
20
|
+
MeterProvider,
|
|
21
|
+
type MetricReader,
|
|
22
|
+
PeriodicExportingMetricReader,
|
|
23
|
+
} from "@opentelemetry/sdk-metrics";
|
|
24
|
+
import {
|
|
25
|
+
ATTR_SERVICE_NAME,
|
|
26
|
+
ATTR_SERVICE_VERSION,
|
|
27
|
+
} from "@opentelemetry/semantic-conventions";
|
|
28
|
+
import {
|
|
29
|
+
OpenTelemetryAdapter,
|
|
30
|
+
type OpenTelemetryAdapterOptions,
|
|
31
|
+
} from "./OpenTelemetryAdapter";
|
|
32
|
+
import { TelemetryService } from "./TelemetryService";
|
|
33
|
+
|
|
34
|
+
type OpenTelemetryExporterKind = "console" | "otlp";
|
|
35
|
+
type OpenTelemetryProtocol = "http/json";
|
|
36
|
+
|
|
37
|
+
export interface OpenTelemetryProviderOptions
|
|
38
|
+
extends Omit<
|
|
39
|
+
OpenTelemetryClientConfig,
|
|
40
|
+
"enabled" | "logsExporter" | "metricsExporter"
|
|
41
|
+
> {
|
|
42
|
+
serviceName?: string;
|
|
43
|
+
serviceVersion?: string;
|
|
44
|
+
enabled?: boolean;
|
|
45
|
+
logsExporter?: string | OpenTelemetryExporterKind[];
|
|
46
|
+
metricsExporter?: string | OpenTelemetryExporterKind[];
|
|
47
|
+
metricExportIntervalMs?: number;
|
|
48
|
+
logMaxQueueSize?: number;
|
|
49
|
+
logBatchSize?: number;
|
|
50
|
+
logBatchTimeoutMs?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CreateOpenTelemetryTelemetryServiceOptions
|
|
54
|
+
extends OpenTelemetryProviderOptions,
|
|
55
|
+
Pick<
|
|
56
|
+
OpenTelemetryAdapterOptions,
|
|
57
|
+
"name" | "distinctId" | "commonProperties"
|
|
58
|
+
> {
|
|
59
|
+
metadata: TelemetryMetadata;
|
|
60
|
+
logger?: BasicLogger;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class OpenTelemetryProvider {
|
|
64
|
+
readonly meterProvider: MeterProvider | null;
|
|
65
|
+
readonly loggerProvider: LoggerProvider | null;
|
|
66
|
+
private readonly options: OpenTelemetryProviderOptions;
|
|
67
|
+
|
|
68
|
+
constructor(options: OpenTelemetryProviderOptions = {}) {
|
|
69
|
+
this.options = options;
|
|
70
|
+
const resource = new Resource({
|
|
71
|
+
[ATTR_SERVICE_NAME]: options.serviceName ?? "cline",
|
|
72
|
+
...(options.serviceVersion
|
|
73
|
+
? { [ATTR_SERVICE_VERSION]: options.serviceVersion }
|
|
74
|
+
: {}),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
this.meterProvider = this.createMeterProvider(resource);
|
|
78
|
+
this.loggerProvider = this.createLoggerProvider(resource);
|
|
79
|
+
|
|
80
|
+
if (this.meterProvider) {
|
|
81
|
+
metrics.setGlobalMeterProvider(this.meterProvider);
|
|
82
|
+
}
|
|
83
|
+
if (this.loggerProvider) {
|
|
84
|
+
logs.setGlobalLoggerProvider(this.loggerProvider);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
createAdapter(
|
|
89
|
+
options: Omit<
|
|
90
|
+
OpenTelemetryAdapterOptions,
|
|
91
|
+
"meterProvider" | "loggerProvider"
|
|
92
|
+
>,
|
|
93
|
+
): OpenTelemetryAdapter {
|
|
94
|
+
return new OpenTelemetryAdapter({
|
|
95
|
+
...options,
|
|
96
|
+
meterProvider: this.meterProvider,
|
|
97
|
+
loggerProvider: this.loggerProvider,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
createTelemetryService(
|
|
102
|
+
options: Omit<
|
|
103
|
+
CreateOpenTelemetryTelemetryServiceOptions,
|
|
104
|
+
keyof OpenTelemetryProviderOptions
|
|
105
|
+
>,
|
|
106
|
+
): ITelemetryService {
|
|
107
|
+
const adapter = this.createAdapter({
|
|
108
|
+
name: options.name,
|
|
109
|
+
enabled: this.options.enabled,
|
|
110
|
+
metadata: options.metadata,
|
|
111
|
+
});
|
|
112
|
+
return new TelemetryService({
|
|
113
|
+
adapters: [adapter],
|
|
114
|
+
distinctId: options.distinctId,
|
|
115
|
+
commonProperties: options.commonProperties,
|
|
116
|
+
logger: options.logger,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async forceFlush(): Promise<void> {
|
|
121
|
+
await Promise.all([
|
|
122
|
+
this.meterProvider?.forceFlush?.(),
|
|
123
|
+
this.loggerProvider?.forceFlush?.(),
|
|
124
|
+
]);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async dispose(): Promise<void> {
|
|
128
|
+
await Promise.all([
|
|
129
|
+
this.meterProvider?.shutdown?.(),
|
|
130
|
+
this.loggerProvider?.shutdown?.(),
|
|
131
|
+
]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private createMeterProvider(resource: Resource): MeterProvider | null {
|
|
135
|
+
const exporters = normalizeExporters(this.options.metricsExporter);
|
|
136
|
+
if (exporters.length === 0) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const interval = Math.max(
|
|
141
|
+
1_000,
|
|
142
|
+
this.options.metricExportIntervalMs ??
|
|
143
|
+
this.options.metricExportInterval ??
|
|
144
|
+
60_000,
|
|
145
|
+
);
|
|
146
|
+
const timeout = Math.min(30_000, Math.floor(interval * 0.8));
|
|
147
|
+
const readers = exporters
|
|
148
|
+
.map((exporter) =>
|
|
149
|
+
createMetricReader(exporter, {
|
|
150
|
+
endpoint: this.options.otlpEndpoint,
|
|
151
|
+
headers: this.options.otlpHeaders,
|
|
152
|
+
insecure: this.options.otlpInsecure ?? false,
|
|
153
|
+
protocol: "http/json",
|
|
154
|
+
interval,
|
|
155
|
+
timeout,
|
|
156
|
+
}),
|
|
157
|
+
)
|
|
158
|
+
.filter((reader): reader is MetricReader => reader !== null);
|
|
159
|
+
|
|
160
|
+
if (readers.length === 0) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return new MeterProvider({
|
|
165
|
+
resource,
|
|
166
|
+
readers,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private createLoggerProvider(resource: Resource): LoggerProvider | null {
|
|
171
|
+
const exporters = normalizeExporters(this.options.logsExporter);
|
|
172
|
+
if (exporters.length === 0) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const provider = new LoggerProvider({ resource });
|
|
177
|
+
for (const exporter of exporters) {
|
|
178
|
+
const logExporter = createLogExporter(exporter, {
|
|
179
|
+
endpoint: this.options.otlpEndpoint,
|
|
180
|
+
headers: this.options.otlpHeaders,
|
|
181
|
+
insecure: this.options.otlpInsecure ?? false,
|
|
182
|
+
protocol: "http/json",
|
|
183
|
+
});
|
|
184
|
+
if (!logExporter) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
provider.addLogRecordProcessor(
|
|
188
|
+
new BatchLogRecordProcessor(logExporter, {
|
|
189
|
+
maxQueueSize: this.options.logMaxQueueSize ?? 2048,
|
|
190
|
+
maxExportBatchSize: this.options.logBatchSize ?? 512,
|
|
191
|
+
scheduledDelayMillis:
|
|
192
|
+
this.options.logBatchTimeoutMs ??
|
|
193
|
+
this.options.logBatchTimeout ??
|
|
194
|
+
5000,
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return provider;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function createOpenTelemetryTelemetryService(
|
|
203
|
+
options: CreateOpenTelemetryTelemetryServiceOptions,
|
|
204
|
+
): { provider: OpenTelemetryProvider; telemetry: ITelemetryService } {
|
|
205
|
+
const provider = new OpenTelemetryProvider(options);
|
|
206
|
+
const telemetry = provider.createTelemetryService(options);
|
|
207
|
+
telemetry.captureRequired("telemetry.provider_created", {
|
|
208
|
+
provider: "opentelemetry",
|
|
209
|
+
enabled: options.enabled ?? true,
|
|
210
|
+
logsExporter: Array.isArray(options.logsExporter)
|
|
211
|
+
? options.logsExporter.join(",")
|
|
212
|
+
: options.logsExporter,
|
|
213
|
+
metricsExporter: Array.isArray(options.metricsExporter)
|
|
214
|
+
? options.metricsExporter.join(",")
|
|
215
|
+
: options.metricsExporter,
|
|
216
|
+
otlpProtocol: options.otlpProtocol,
|
|
217
|
+
hasOtlpEndpoint: Boolean(options.otlpEndpoint),
|
|
218
|
+
serviceName: options.serviceName,
|
|
219
|
+
serviceVersion: options.serviceVersion,
|
|
220
|
+
});
|
|
221
|
+
return {
|
|
222
|
+
provider,
|
|
223
|
+
telemetry,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function createConfiguredTelemetryService(
|
|
228
|
+
options: CreateOpenTelemetryTelemetryServiceOptions,
|
|
229
|
+
): {
|
|
230
|
+
provider?: OpenTelemetryProvider;
|
|
231
|
+
telemetry: ITelemetryService;
|
|
232
|
+
} {
|
|
233
|
+
if (options.enabled !== true) {
|
|
234
|
+
return {
|
|
235
|
+
telemetry: new TelemetryService(),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return createOpenTelemetryTelemetryService(options);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function normalizeExporters(
|
|
243
|
+
exporters: OpenTelemetryProviderOptions["logsExporter"],
|
|
244
|
+
): OpenTelemetryExporterKind[] {
|
|
245
|
+
if (!exporters) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
const values = Array.isArray(exporters) ? exporters : exporters.split(",");
|
|
249
|
+
return values
|
|
250
|
+
.map((value) => value.trim())
|
|
251
|
+
.filter(
|
|
252
|
+
(value): value is OpenTelemetryExporterKind =>
|
|
253
|
+
value === "console" || value === "otlp",
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function createLogExporter(
|
|
258
|
+
exporter: OpenTelemetryExporterKind,
|
|
259
|
+
options: {
|
|
260
|
+
endpoint?: string;
|
|
261
|
+
headers?: Record<string, string>;
|
|
262
|
+
insecure: boolean;
|
|
263
|
+
protocol: OpenTelemetryProtocol;
|
|
264
|
+
},
|
|
265
|
+
): LogRecordExporter | null {
|
|
266
|
+
if (exporter === "console") {
|
|
267
|
+
return new ConsoleLogRecordExporter();
|
|
268
|
+
}
|
|
269
|
+
if (!options.endpoint) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const endpoint = ensurePathSuffix(options.endpoint, "/v1/logs");
|
|
274
|
+
return new OTLPLogExporterHttp({
|
|
275
|
+
url: endpoint,
|
|
276
|
+
headers: options.headers,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function createMetricReader(
|
|
281
|
+
exporter: OpenTelemetryExporterKind,
|
|
282
|
+
options: {
|
|
283
|
+
endpoint?: string;
|
|
284
|
+
headers?: Record<string, string>;
|
|
285
|
+
insecure: boolean;
|
|
286
|
+
protocol: OpenTelemetryProtocol;
|
|
287
|
+
interval: number;
|
|
288
|
+
timeout: number;
|
|
289
|
+
},
|
|
290
|
+
): MetricReader | null {
|
|
291
|
+
if (exporter === "console") {
|
|
292
|
+
return new PeriodicExportingMetricReader({
|
|
293
|
+
exporter: new ConsoleMetricExporter(),
|
|
294
|
+
exportIntervalMillis: options.interval,
|
|
295
|
+
exportTimeoutMillis: options.timeout,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
if (!options.endpoint) {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const endpoint = ensurePathSuffix(options.endpoint, "/v1/metrics");
|
|
303
|
+
return new PeriodicExportingMetricReader({
|
|
304
|
+
exporter: new OTLPMetricExporterHttp({
|
|
305
|
+
url: endpoint,
|
|
306
|
+
headers: options.headers,
|
|
307
|
+
}),
|
|
308
|
+
exportIntervalMillis: options.interval,
|
|
309
|
+
exportTimeoutMillis: options.timeout,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function ensurePathSuffix(endpoint: string, suffix: string): string {
|
|
314
|
+
const url = new URL(endpoint);
|
|
315
|
+
const normalized = url.pathname.endsWith("/")
|
|
316
|
+
? url.pathname.slice(0, -1)
|
|
317
|
+
: url.pathname;
|
|
318
|
+
url.pathname = normalized.endsWith(suffix)
|
|
319
|
+
? normalized
|
|
320
|
+
: `${normalized}${suffix}`;
|
|
321
|
+
return url.toString();
|
|
322
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { BasicLogger } from "@clinebot/shared";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { ITelemetryAdapter } from "./ITelemetryAdapter";
|
|
4
|
+
import { TelemetryService } from "./TelemetryService";
|
|
5
|
+
|
|
6
|
+
describe("TelemetryService", () => {
|
|
7
|
+
it("merges metadata and forwards calls to adapters", async () => {
|
|
8
|
+
const { adapter, emit, recordCounter } = createAdapter();
|
|
9
|
+
const service = new TelemetryService({
|
|
10
|
+
adapters: [adapter],
|
|
11
|
+
metadata: {
|
|
12
|
+
extension_version: "1.2.3",
|
|
13
|
+
cline_type: "cli",
|
|
14
|
+
},
|
|
15
|
+
distinctId: "distinct-1",
|
|
16
|
+
commonProperties: {
|
|
17
|
+
organization_id: "org-1",
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
service.capture({
|
|
22
|
+
event: "session.started",
|
|
23
|
+
properties: { sessionId: "session-1" },
|
|
24
|
+
});
|
|
25
|
+
service.recordCounter("cline.session.starts.total", 1, {
|
|
26
|
+
sessionId: "session-1",
|
|
27
|
+
});
|
|
28
|
+
await service.flush();
|
|
29
|
+
await service.dispose();
|
|
30
|
+
|
|
31
|
+
expect(emit).toHaveBeenCalledWith(
|
|
32
|
+
"session.started",
|
|
33
|
+
expect.objectContaining({
|
|
34
|
+
sessionId: "session-1",
|
|
35
|
+
organization_id: "org-1",
|
|
36
|
+
extension_version: "1.2.3",
|
|
37
|
+
cline_type: "cli",
|
|
38
|
+
distinct_id: "distinct-1",
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
expect(recordCounter).toHaveBeenCalledWith(
|
|
42
|
+
"cline.session.starts.total",
|
|
43
|
+
1,
|
|
44
|
+
expect.objectContaining({
|
|
45
|
+
sessionId: "session-1",
|
|
46
|
+
distinct_id: "distinct-1",
|
|
47
|
+
}),
|
|
48
|
+
undefined,
|
|
49
|
+
false,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("mirrors telemetry events into the logger when provided", () => {
|
|
54
|
+
const logger: BasicLogger = {
|
|
55
|
+
debug: vi.fn(),
|
|
56
|
+
info: vi.fn(),
|
|
57
|
+
warn: vi.fn(),
|
|
58
|
+
error: vi.fn(),
|
|
59
|
+
};
|
|
60
|
+
const service = new TelemetryService({
|
|
61
|
+
logger,
|
|
62
|
+
metadata: {
|
|
63
|
+
extension_version: "1.2.3",
|
|
64
|
+
cline_type: "cli",
|
|
65
|
+
},
|
|
66
|
+
distinctId: "distinct-1",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
service.capture({
|
|
70
|
+
event: "session.started",
|
|
71
|
+
properties: { sessionId: "session-1" },
|
|
72
|
+
});
|
|
73
|
+
service.captureRequired("user.opt_out", { reason: "manual" });
|
|
74
|
+
service.recordCounter("cline.session.starts.total", 1, {
|
|
75
|
+
sessionId: "session-1",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
79
|
+
"telemetry.event",
|
|
80
|
+
expect.objectContaining({
|
|
81
|
+
adapter: "LoggerTelemetryAdapter",
|
|
82
|
+
event: "session.started",
|
|
83
|
+
properties: expect.objectContaining({
|
|
84
|
+
sessionId: "session-1",
|
|
85
|
+
extension_version: "1.2.3",
|
|
86
|
+
distinct_id: "distinct-1",
|
|
87
|
+
}),
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
91
|
+
"telemetry.required_event",
|
|
92
|
+
expect.objectContaining({
|
|
93
|
+
adapter: "LoggerTelemetryAdapter",
|
|
94
|
+
event: "user.opt_out",
|
|
95
|
+
properties: expect.objectContaining({
|
|
96
|
+
reason: "manual",
|
|
97
|
+
extension_version: "1.2.3",
|
|
98
|
+
}),
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
expect(logger.debug).toHaveBeenCalledWith(
|
|
102
|
+
"telemetry.metric",
|
|
103
|
+
expect.objectContaining({
|
|
104
|
+
adapter: "LoggerTelemetryAdapter",
|
|
105
|
+
instrument: "counter",
|
|
106
|
+
name: "cline.session.starts.total",
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
function createAdapter(): {
|
|
113
|
+
adapter: ITelemetryAdapter;
|
|
114
|
+
emit: ReturnType<typeof vi.fn>;
|
|
115
|
+
recordCounter: ReturnType<typeof vi.fn>;
|
|
116
|
+
} {
|
|
117
|
+
const emit = vi.fn();
|
|
118
|
+
const recordCounter = vi.fn();
|
|
119
|
+
return {
|
|
120
|
+
adapter: {
|
|
121
|
+
name: "test",
|
|
122
|
+
emit,
|
|
123
|
+
emitRequired: vi.fn(),
|
|
124
|
+
recordCounter,
|
|
125
|
+
recordHistogram: vi.fn(),
|
|
126
|
+
recordGauge: vi.fn(),
|
|
127
|
+
isEnabled: vi.fn(() => true),
|
|
128
|
+
flush: vi.fn().mockResolvedValue(undefined),
|
|
129
|
+
dispose: vi.fn().mockResolvedValue(undefined),
|
|
130
|
+
},
|
|
131
|
+
emit,
|
|
132
|
+
recordCounter,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BasicLogger,
|
|
3
|
+
ITelemetryService,
|
|
4
|
+
TelemetryMetadata,
|
|
5
|
+
TelemetryProperties,
|
|
6
|
+
} from "@clinebot/shared";
|
|
7
|
+
import type { ITelemetryAdapter } from "./ITelemetryAdapter";
|
|
8
|
+
import { LoggerTelemetryAdapter } from "./LoggerTelemetryAdapter";
|
|
9
|
+
|
|
10
|
+
export interface TelemetryServiceOptions {
|
|
11
|
+
adapters?: ITelemetryAdapter[];
|
|
12
|
+
metadata?: Partial<TelemetryMetadata>;
|
|
13
|
+
distinctId?: string;
|
|
14
|
+
commonProperties?: TelemetryProperties;
|
|
15
|
+
logger?: BasicLogger;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class TelemetryService implements ITelemetryService {
|
|
19
|
+
private adapters: ITelemetryAdapter[];
|
|
20
|
+
private metadata: Partial<TelemetryMetadata>;
|
|
21
|
+
private distinctId?: string;
|
|
22
|
+
private commonProperties: TelemetryProperties;
|
|
23
|
+
|
|
24
|
+
constructor(options: TelemetryServiceOptions = {}) {
|
|
25
|
+
this.adapters = [...(options.adapters ?? [])];
|
|
26
|
+
if (options.logger) {
|
|
27
|
+
this.adapters.push(
|
|
28
|
+
new LoggerTelemetryAdapter({ logger: options.logger }),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
this.metadata = { ...(options.metadata ?? {}) };
|
|
32
|
+
this.distinctId = options.distinctId;
|
|
33
|
+
this.commonProperties = { ...(options.commonProperties ?? {}) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
addAdapter(adapter: ITelemetryAdapter): void {
|
|
37
|
+
this.adapters.push(adapter);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
setDistinctId(distinctId?: string): void {
|
|
41
|
+
this.distinctId = distinctId;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setMetadata(metadata: Partial<TelemetryMetadata>): void {
|
|
45
|
+
this.metadata = { ...metadata };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
updateMetadata(metadata: Partial<TelemetryMetadata>): void {
|
|
49
|
+
this.metadata = {
|
|
50
|
+
...this.metadata,
|
|
51
|
+
...metadata,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
setCommonProperties(properties: TelemetryProperties): void {
|
|
56
|
+
this.commonProperties = { ...properties };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
updateCommonProperties(properties: TelemetryProperties): void {
|
|
60
|
+
this.commonProperties = {
|
|
61
|
+
...this.commonProperties,
|
|
62
|
+
...properties,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
isEnabled(): boolean {
|
|
67
|
+
return this.adapters.some((adapter) => adapter.isEnabled());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
capture(input: { event: string; properties?: TelemetryProperties }): void {
|
|
71
|
+
const properties = this.buildAttributes(input.properties);
|
|
72
|
+
for (const adapter of this.adapters) {
|
|
73
|
+
adapter.emit(input.event, properties);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
captureRequired(event: string, properties?: TelemetryProperties): void {
|
|
78
|
+
const merged = this.buildAttributes(properties);
|
|
79
|
+
for (const adapter of this.adapters) {
|
|
80
|
+
adapter.emitRequired(event, merged);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
recordCounter(
|
|
85
|
+
name: string,
|
|
86
|
+
value: number,
|
|
87
|
+
attributes?: TelemetryProperties,
|
|
88
|
+
description?: string,
|
|
89
|
+
required = false,
|
|
90
|
+
): void {
|
|
91
|
+
const merged = this.buildAttributes(attributes);
|
|
92
|
+
for (const adapter of this.adapters) {
|
|
93
|
+
adapter.recordCounter(name, value, merged, description, required);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
recordHistogram(
|
|
98
|
+
name: string,
|
|
99
|
+
value: number,
|
|
100
|
+
attributes?: TelemetryProperties,
|
|
101
|
+
description?: string,
|
|
102
|
+
required = false,
|
|
103
|
+
): void {
|
|
104
|
+
const merged = this.buildAttributes(attributes);
|
|
105
|
+
for (const adapter of this.adapters) {
|
|
106
|
+
adapter.recordHistogram(name, value, merged, description, required);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
recordGauge(
|
|
111
|
+
name: string,
|
|
112
|
+
value: number | null,
|
|
113
|
+
attributes?: TelemetryProperties,
|
|
114
|
+
description?: string,
|
|
115
|
+
required = false,
|
|
116
|
+
): void {
|
|
117
|
+
const merged = this.buildAttributes(attributes);
|
|
118
|
+
for (const adapter of this.adapters) {
|
|
119
|
+
adapter.recordGauge(name, value, merged, description, required);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async flush(): Promise<void> {
|
|
124
|
+
await Promise.all(this.adapters.map((adapter) => adapter.flush()));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async dispose(): Promise<void> {
|
|
128
|
+
await Promise.all(this.adapters.map((adapter) => adapter.dispose()));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private buildAttributes(
|
|
132
|
+
properties?: TelemetryProperties,
|
|
133
|
+
): TelemetryProperties {
|
|
134
|
+
return {
|
|
135
|
+
...this.commonProperties,
|
|
136
|
+
...properties,
|
|
137
|
+
...this.metadata,
|
|
138
|
+
...(this.distinctId ? { distinct_id: this.distinctId } : {}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|