Skip to content

Commit 88a8bf1

Browse files
chore: improve telemetry collecting using generated SDK (#2424)
* feat: add improved telemetry using generated SDK * chore: replace BatchSpanProcessor with the SimpleSpanProcessor * chore: remove unused method * chore: replace send proper method * refactor: remove file and add logic to the telemerty file * refactor: naming in SDK * chore: add missing properties * chore: remove additional logic to get user id, install ulid package and change user_id to anonymous_id * chore: rename user_id to anonymous_id in otel and fix typing * chore: rename functions and add name of cached file * chore: replace generating id with crypto with ulid * chore: change comment * docs: add points what data we collected * chore: remove import and check for undefined manually * fix: cleanObject function types and logic * feat: add proper package with types and add missing properties for new SDK * chore: update lock file, fix test and add new to reach the threshold * chore: add changeset * chore: improve tests for new functions * feat: add missing properties * feat: add tests to pass the unit tests threshlod * fix: unit tests * Update docs/@v2/usage-data.md * chore: remove anonymous id if it is in CI * chore: add proper files and changes * chore: add empty data for project and organization to match general SDK structure * feat: add functionality to have ann id in all cases, update version of SDK, change origin constant to cli * fix: cleaning file in command if it is not exists * chore: revert changes --------- Co-authored-by: Jacek Łękawa <164185257+JLekawa@users.noreply.github.com>
1 parent 2121d76 commit 88a8bf1

10 files changed

Lines changed: 475 additions & 165 deletions

File tree

.changeset/new-mammals-accept.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@redocly/cli": patch
3+
---
4+
5+
Updated telemetry implementation to use standardized OpenTelemetry format.

docs/@v2/usage-data.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ When a command is run, the following data is collected:
1919
- whether the `redocly.yaml` configuration file exists
2020
- API specification version
2121
- Platform (Linux, macOS, Windows)
22+
- Anonymous ID (a randomly generated identifier that doesn't contain personal information)
23+
- Command execution time
2224

2325
Values such as file names, organization IDs, and URLs are removed, replaced by just "URL" or "file", etc.
2426

package-lock.json

Lines changed: 203 additions & 76 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,12 @@
6262
"set-cookie-parser": "^2.3.5",
6363
"simple-websocket": "^9.0.0",
6464
"styled-components": "^6.0.7",
65+
"ulid": "^3.0.1",
6566
"undici": "^6.21.3",
6667
"yargs": "17.0.1"
6768
},
6869
"devDependencies": {
70+
"@redocly/cli-otel": "^0.0.4",
6971
"@types/configstore": "^5.0.1",
7072
"@types/cookie": "0.6.0",
7173
"@types/har-format": "^1.2.16",

packages/cli/src/__tests__/utils/telemetry.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { collectXSecurityAuthTypes } from '../../utils/telemetry.js';
1+
import { tmpdir } from 'node:os';
2+
import { join } from 'node:path';
3+
import { existsSync, unlinkSync } from 'node:fs';
4+
import {
5+
collectXSecurityAuthTypes,
6+
cacheAnonymousId,
7+
getCachedAnonymousId,
8+
} from '../../utils/telemetry.js';
9+
import { ANONYMOUS_ID_CACHE_FILE } from '../../utils/constants.js';
210

311
import type { ArazzoDefinition } from '@redocly/openapi-core';
412

@@ -86,4 +94,86 @@ describe('collectXSecurityAuthTypes', () => {
8694
'SomeSchemeName',
8795
]);
8896
});
97+
98+
it('should handle documents with no workflows', () => {
99+
const respectXSecurityAuthTypesAndSchemeName: string[] = [];
100+
const arazzoDocument = {} as Partial<ArazzoDefinition>;
101+
collectXSecurityAuthTypes(arazzoDocument, respectXSecurityAuthTypesAndSchemeName);
102+
expect(respectXSecurityAuthTypesAndSchemeName).toEqual([]);
103+
});
104+
105+
it('should handle workflows with no x-security', () => {
106+
const respectXSecurityAuthTypesAndSchemeName: string[] = [];
107+
const arazzoDocument = {
108+
workflows: [
109+
{
110+
workflowId: 'workflow1',
111+
steps: [],
112+
},
113+
],
114+
} as Partial<ArazzoDefinition>;
115+
collectXSecurityAuthTypes(arazzoDocument, respectXSecurityAuthTypesAndSchemeName);
116+
expect(respectXSecurityAuthTypesAndSchemeName).toEqual([]);
117+
});
118+
});
119+
120+
describe('cacheAnonymousId and getCachedAnonymousId', () => {
121+
const anonymousIdFile = join(tmpdir(), ANONYMOUS_ID_CACHE_FILE);
122+
123+
beforeEach(() => {
124+
if (existsSync(anonymousIdFile)) {
125+
unlinkSync(anonymousIdFile);
126+
}
127+
vi.unstubAllEnvs();
128+
});
129+
130+
afterEach(() => {
131+
if (existsSync(anonymousIdFile)) {
132+
unlinkSync(anonymousIdFile);
133+
}
134+
vi.unstubAllEnvs();
135+
});
136+
137+
it('should cache and retrieve anonymous ID if it is not in CI', () => {
138+
process.env.CI = '';
139+
140+
const testId = 'ann_test123';
141+
142+
cacheAnonymousId(testId);
143+
const retrievedId = getCachedAnonymousId();
144+
145+
expect(retrievedId).toBe(testId);
146+
expect(existsSync(anonymousIdFile)).toBe(true);
147+
});
148+
149+
it('should not cache anonymous ID in CI environment', () => {
150+
process.env.CI = 'true';
151+
const testId = 'ann_test123';
152+
153+
cacheAnonymousId(testId);
154+
155+
expect(existsSync(anonymousIdFile)).toBe(false);
156+
});
157+
158+
it('should not retrieve anonymous ID in CI environment', () => {
159+
const testId = 'ann_test123';
160+
cacheAnonymousId(testId);
161+
162+
process.env.CI = 'true';
163+
const retrievedId = getCachedAnonymousId();
164+
165+
expect(retrievedId).toBeUndefined();
166+
});
167+
168+
it('should return undefined if no cached ID exists', () => {
169+
const retrievedId = getCachedAnonymousId();
170+
171+
expect(retrievedId).toBeUndefined();
172+
});
173+
174+
it('should not cache if anonymousId is empty', () => {
175+
cacheAnonymousId('');
176+
177+
expect(existsSync(anonymousIdFile)).toBe(false);
178+
});
89179
});

packages/cli/src/__tests__/wrapper.test.ts

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,22 @@ describe('commandWrapper', () => {
4242
await wrappedHandler({} as any);
4343
expect(handleLint).toHaveBeenCalledTimes(1);
4444
expect(sendTelemetry).toHaveBeenCalledTimes(1);
45-
expect(sendTelemetry).toHaveBeenCalledWith({
46-
config: {
47-
resolvedConfig: {
48-
telemetry: 'on',
45+
expect(sendTelemetry).toHaveBeenCalledWith(
46+
expect.objectContaining({
47+
config: {
48+
resolvedConfig: {
49+
telemetry: 'on',
50+
},
4951
},
50-
},
51-
argv: {},
52-
exit_code: 0,
53-
spec_version: 'oas3_1',
54-
spec_keyword: 'openapi',
55-
spec_full_version: '3.1.0',
56-
respect_x_security_auth_types: [],
57-
});
52+
argv: {},
53+
execution_time: expect.any(Number),
54+
exit_code: 0,
55+
spec_version: 'oas3_1',
56+
spec_keyword: 'openapi',
57+
spec_full_version: '3.1.0',
58+
respect_x_security_auth_types: [],
59+
})
60+
);
5861
});
5962

6063
it('should not collect spec version if the file is not parsed to json', async () => {
@@ -70,19 +73,22 @@ describe('commandWrapper', () => {
7073
await wrappedHandler({} as any);
7174
expect(handleLint).toHaveBeenCalledTimes(1);
7275
expect(sendTelemetry).toHaveBeenCalledTimes(1);
73-
expect(sendTelemetry).toHaveBeenCalledWith({
74-
config: {
75-
resolvedConfig: {
76-
telemetry: 'on',
76+
expect(sendTelemetry).toHaveBeenCalledWith(
77+
expect.objectContaining({
78+
config: {
79+
resolvedConfig: {
80+
telemetry: 'on',
81+
},
7782
},
78-
},
79-
argv: {},
80-
exit_code: 0,
81-
spec_version: undefined,
82-
spec_keyword: undefined,
83-
spec_full_version: undefined,
84-
respect_x_security_auth_types: [],
85-
});
83+
argv: {},
84+
execution_time: expect.any(Number),
85+
exit_code: 0,
86+
spec_version: undefined,
87+
spec_keyword: undefined,
88+
spec_full_version: undefined,
89+
respect_x_security_auth_types: [],
90+
})
91+
);
8692
});
8793

8894
it('should NOT send telemetry if there is "telemetry: off" in the config', async () => {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export const OTEL_URL = 'https://otel.cloud.redocly.com';
22
export const OTEL_TRACES_URL = process.env.OTEL_TRACES_URL || `${OTEL_URL}/v1/traces`;
33
export const DEFAULT_FETCH_TIMEOUT = 3000;
4+
export const ANONYMOUS_ID_CACHE_FILE = 'redocly-cli-anonymous-id';

packages/cli/src/utils/otel.ts

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,19 @@ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
44
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
55
import { version } from './package.js';
66
import { OTEL_TRACES_URL, DEFAULT_FETCH_TIMEOUT } from './constants.js';
7+
import { ulid } from 'ulid';
78

8-
import type { Analytics } from './telemetry.js';
9-
10-
type Events = {
11-
[key: string]: Analytics;
12-
};
9+
import type { CloudEvents } from '@redocly/cli-otel';
1310

1411
export class OtelServerTelemetry {
15-
send<K extends keyof Events>(event: K, data: Events[K]): void {
16-
const nodeTracerProvider = new NodeTracerProvider({
12+
private nodeTracerProvider: NodeTracerProvider;
13+
14+
constructor() {
15+
this.nodeTracerProvider = new NodeTracerProvider({
1716
resource: resourceFromAttributes({
1817
[ATTR_SERVICE_NAME]: `redocly-cli`,
1918
[ATTR_SERVICE_VERSION]: `@redocly/cli@${version}`,
19+
session_id: `ses_${ulid()}`,
2020
}),
2121
spanProcessors: [
2222
new SimpleSpanProcessor(
@@ -28,23 +28,48 @@ export class OtelServerTelemetry {
2828
),
2929
],
3030
});
31+
}
3132

32-
const time = new Date();
33-
const eventId = crypto.randomUUID();
34-
const tracer = nodeTracerProvider.getTracer('CliTelemetry');
35-
const span = tracer.startSpan(`event.${event}`, {
36-
attributes: {
37-
'cloudevents.event_client.id': eventId,
38-
'cloudevents.event_client.type': event,
39-
},
40-
startTime: time,
41-
});
42-
for (const [key, value] of Object.entries(data)) {
33+
send(cloudEvent: CloudEvents.Messages): void {
34+
const time = cloudEvent.time ? new Date(cloudEvent.time) : new Date();
35+
const tracer = this.nodeTracerProvider.getTracer('CliTelemetry');
36+
const spanName = `event.${cloudEvent.data.command}`;
37+
38+
const attributes: Record<string, string | number | boolean | undefined> = {
39+
'cloudevents.event_id': cloudEvent.id,
40+
'cloudevents.event_type': cloudEvent.type,
41+
'cloudevents.event_source': cloudEvent.source,
42+
'cloudevents.event_spec_version': cloudEvent.specversion,
43+
'cloudevents.productType': cloudEvent.productType,
44+
'cloudevents.event_data_content_type':
45+
cloudEvent.datacontenttype || 'application/json; charset=utf-8',
46+
'cloudevents.event_time': time.toISOString(),
47+
'cloudevents.event_version': '1.0.0',
48+
'cloudevents.origin': cloudEvent.origin,
49+
'cloudevents.project.id': '',
50+
'cloudevents.project.slug': '',
51+
'cloudevents.organization.id': '',
52+
'cloudevents.organization.slug': '',
53+
'cloudevents.event_origin': cloudEvent.productType,
54+
'cloudevents.event_source_details.id': cloudEvent.sourceDetails?.id ?? `ann_${ulid()}`,
55+
'cloudevents.event_source_details.object': cloudEvent.sourceDetails?.object ?? 'anonymous',
56+
'cloudevents.event_source_details.uri': cloudEvent.sourceDetails?.uri ?? '',
57+
'cloudevents.event_data.os_platform': cloudEvent.os_platform,
58+
'cloudevents.event_data.environment': cloudEvent.environment,
59+
};
60+
61+
for (const [key, value] of Object.entries(cloudEvent.data)) {
4362
const keySnakeCase = key.replace(/([A-Z])/g, '_$1').toLowerCase();
4463
if (value !== undefined) {
45-
span.setAttribute(`cloudevents.event_data.${keySnakeCase}`, value);
64+
attributes[`cloudevents.event_data.${keySnakeCase}`] = value;
4665
}
4766
}
67+
68+
const span = tracer.startSpan(spanName, {
69+
attributes,
70+
startTime: time,
71+
});
72+
4873
span.end(time);
4974
}
5075
}

0 commit comments

Comments
 (0)