@n8n/frontend-module-otel 0.1.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.
@@ -0,0 +1,36 @@
1
+ import { N8nPlugin } from '@n8n/design-system';
2
+ import { i18nInstance } from '@n8n/i18n';
3
+ import { render, type RenderOptions as TestingLibraryRenderOptions } from '@testing-library/vue';
4
+ import type { Pinia } from 'pinia';
5
+ import { PiniaVuePlugin } from 'pinia';
6
+
7
+ /**
8
+ * The shell's `@/__tests__/render` cannot come with the module: it provides the
9
+ * workflow-document store and installs shell-only plugins. This module renders one
10
+ * settings view built from design-system components, so it needs the design-system
11
+ * directives, i18n and a pinia — nothing else.
12
+ */
13
+ export type RenderOptions<T> = Omit<TestingLibraryRenderOptions<T>, 'props'> & {
14
+ pinia?: Pinia;
15
+ props?: Partial<TestingLibraryRenderOptions<T>['props']>;
16
+ };
17
+
18
+ export function createComponentRenderer<T>(component: T, defaultOptions: RenderOptions<T> = {}) {
19
+ return (options: RenderOptions<T> = {}) => {
20
+ const { pinia, ...renderOptions } = { ...defaultOptions, ...options };
21
+
22
+ return render(component, {
23
+ ...renderOptions,
24
+ global: {
25
+ ...renderOptions.global,
26
+ plugins: [
27
+ i18nInstance,
28
+ PiniaVuePlugin,
29
+ N8nPlugin,
30
+ ...(renderOptions.global?.plugins ?? []),
31
+ ...(pinia ? [pinia] : []),
32
+ ],
33
+ },
34
+ } as TestingLibraryRenderOptions<T>);
35
+ };
36
+ }
@@ -0,0 +1,21 @@
1
+ // The shared jsdom harness — observers, matchMedia, canvas, timers, teardown guards.
2
+ import '@n8n/vitest-config/setup/frontend';
3
+
4
+ import { loadLanguage, type LocaleMessages } from '@n8n/i18n';
5
+ import englishBaseText from '@n8n/i18n/locales/en.json';
6
+ import { createPinia, setActivePinia } from 'pinia';
7
+ import { beforeEach } from 'vitest';
8
+
9
+ // Framework boot stays per-package on purpose: `@n8n/i18n` devDepends on
10
+ // `@n8n/vitest-config`, so booting i18n from inside the shared harness would
11
+ // close a turbo build cycle.
12
+ //
13
+ // `useI18n()` reads a module-level singleton, so this runs once at import and
14
+ // needs no app instance — but `baseText` returns the key itself until the
15
+ // messages are loaded, and this module's strings still live in the central
16
+ // `en.json` (per-module locales are a later wave).
17
+ loadLanguage('en', englishBaseText as unknown as LocaleMessages);
18
+
19
+ beforeEach(() => {
20
+ setActivePinia(createPinia());
21
+ });
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ // The module's only public entry. The shell imports the descriptor from here via
2
+ // `modules.manifest.ts`; anything else the shell (or a test) needs must be exported
3
+ // here too — deep paths into `src/` are not part of the contract.
4
+ export { OtelModule } from './otel.module';
@@ -0,0 +1,86 @@
1
+ import type { IRestApiContext } from '@n8n/rest-api-client';
2
+ import { makeRestApiRequest } from '@n8n/rest-api-client';
3
+
4
+ import { getOtelSettings, updateOtelSettings, sendOtelTestTrace } from './otel.api';
5
+ import type { OtelSettingsResponse, OtelTestConnection } from './otel.api';
6
+
7
+ vi.mock('@n8n/rest-api-client', () => ({
8
+ makeRestApiRequest: vi.fn(),
9
+ }));
10
+
11
+ const requestMock = vi.mocked(makeRestApiRequest);
12
+
13
+ const context: IRestApiContext = { baseUrl: 'http://localhost:5678', pushRef: '' };
14
+
15
+ const makeSettings = (overrides: Partial<OtelSettingsResponse> = {}): OtelSettingsResponse => ({
16
+ enabled: false,
17
+ exporterEndpoint: 'http://localhost:4318',
18
+ exporterTracingPath: '/v1/traces',
19
+ exporterServiceName: 'n8n',
20
+ exporterHeaders: '',
21
+ tracesSampleRate: 1.0,
22
+ startupConnectivityTimeoutMs: 2000,
23
+ includeNodeSpans: true,
24
+ injectOutbound: true,
25
+ productionExecutionsOnly: true,
26
+ envManagedFields: [],
27
+ ...overrides,
28
+ });
29
+
30
+ describe('otel.api', () => {
31
+ beforeEach(() => {
32
+ requestMock.mockReset();
33
+ });
34
+
35
+ describe('getOtelSettings', () => {
36
+ it('makes a GET request to /otel/settings and returns the response', async () => {
37
+ const response = makeSettings({ enabled: true });
38
+ requestMock.mockResolvedValueOnce(response);
39
+
40
+ const result = await getOtelSettings(context);
41
+
42
+ expect(requestMock).toHaveBeenCalledWith(context, 'GET', '/otel/settings');
43
+ expect(result).toEqual(response);
44
+ });
45
+ });
46
+
47
+ describe('updateOtelSettings', () => {
48
+ it('makes a PUT request to /otel/settings with the settings payload', async () => {
49
+ const payload = makeSettings({ enabled: true, exporterEndpoint: 'https://collector.io' });
50
+ const response = makeSettings({ ...payload });
51
+ requestMock.mockResolvedValueOnce(response);
52
+
53
+ const result = await updateOtelSettings(context, payload);
54
+
55
+ expect(requestMock).toHaveBeenCalledWith(context, 'PUT', '/otel/settings', payload);
56
+ expect(result).toEqual(response);
57
+ });
58
+ });
59
+
60
+ describe('sendOtelTestTrace', () => {
61
+ const connection: OtelTestConnection = {
62
+ exporterEndpoint: 'https://collector.io',
63
+ exporterTracingPath: '/v1/traces',
64
+ exporterServiceName: 'n8n',
65
+ exporterHeaders: 'auth=token',
66
+ startupConnectivityTimeoutMs: 2000,
67
+ };
68
+
69
+ it('makes a POST request to /otel/test-trace with the connection payload', async () => {
70
+ requestMock.mockResolvedValueOnce({ success: true });
71
+
72
+ const result = await sendOtelTestTrace(context, connection);
73
+
74
+ expect(requestMock).toHaveBeenCalledWith(context, 'POST', '/otel/test-trace', connection);
75
+ expect(result).toEqual({ success: true });
76
+ });
77
+
78
+ it('returns the failure result from the response', async () => {
79
+ requestMock.mockResolvedValueOnce({ success: false, error: '401 Unauthorized' });
80
+
81
+ const result = await sendOtelTestTrace(context, connection);
82
+
83
+ expect(result).toEqual({ success: false, error: '401 Unauthorized' });
84
+ });
85
+ });
86
+ });
@@ -0,0 +1,48 @@
1
+ import type { IRestApiContext } from '@n8n/rest-api-client';
2
+ import { makeRestApiRequest } from '@n8n/rest-api-client';
3
+
4
+ export type OtelSettings = {
5
+ enabled: boolean;
6
+ exporterEndpoint: string;
7
+ exporterTracingPath: string;
8
+ exporterServiceName: string;
9
+ exporterHeaders: string;
10
+ tracesSampleRate: number;
11
+ startupConnectivityTimeoutMs: number;
12
+ includeNodeSpans: boolean;
13
+ injectOutbound: boolean;
14
+ productionExecutionsOnly: boolean;
15
+ };
16
+
17
+ export type OtelSettingsResponse = OtelSettings & {
18
+ envManagedFields: Array<keyof OtelSettings>;
19
+ };
20
+
21
+ export async function getOtelSettings(context: IRestApiContext): Promise<OtelSettingsResponse> {
22
+ return await makeRestApiRequest(context, 'GET', '/otel/settings');
23
+ }
24
+
25
+ export async function updateOtelSettings(
26
+ context: IRestApiContext,
27
+ settings: OtelSettings,
28
+ ): Promise<OtelSettingsResponse> {
29
+ return await makeRestApiRequest(context, 'PUT', '/otel/settings', settings);
30
+ }
31
+
32
+ export type OtelTestConnection = Pick<
33
+ OtelSettings,
34
+ | 'exporterEndpoint'
35
+ | 'exporterTracingPath'
36
+ | 'exporterServiceName'
37
+ | 'exporterHeaders'
38
+ | 'startupConnectivityTimeoutMs'
39
+ >;
40
+
41
+ export type OtelTestTraceResponse = { success: true } | { success: false; error: string };
42
+
43
+ export async function sendOtelTestTrace(
44
+ context: IRestApiContext,
45
+ connection: OtelTestConnection,
46
+ ): Promise<OtelTestTraceResponse> {
47
+ return await makeRestApiRequest(context, 'POST', '/otel/test-trace', connection);
48
+ }
@@ -0,0 +1,26 @@
1
+ export const OTEL_STORE = 'otel';
2
+
3
+ /**
4
+ * Route name for the OpenTelemetry settings page. Owned by this module rather
5
+ * than by the shared `VIEWS` enum, so the module can be packaged without the
6
+ * shell holding one of its identifiers. The value is unchanged, so existing
7
+ * URLs and telemetry keep resolving.
8
+ */
9
+ export const OTEL_SETTINGS_VIEW = 'SettingsOpenTelemetryView';
10
+
11
+ /** Name of the span emitted by the "Send test trace" button — shown in the result copy. */
12
+ export const OTEL_TEST_SPAN_NAME = 'n8n.test_trace';
13
+
14
+ /** Maps each settings field to its env-var name — shown in per-field tooltips. */
15
+ export const OTEL_FIELD_ENV_VARS = {
16
+ enabled: 'N8N_OTEL_ENABLED',
17
+ exporterEndpoint: 'N8N_OTEL_EXPORTER_OTLP_ENDPOINT',
18
+ exporterTracingPath: 'N8N_OTEL_EXPORTER_OTLP_TRACING_PATH',
19
+ exporterServiceName: 'N8N_OTEL_EXPORTER_SERVICE_NAME',
20
+ exporterHeaders: 'N8N_OTEL_EXPORTER_OTLP_HEADERS',
21
+ tracesSampleRate: 'N8N_OTEL_TRACES_SAMPLE_RATE',
22
+ startupConnectivityTimeoutMs: 'N8N_OTEL_STARTUP_CONNECTIVITY_TIMEOUT_MS',
23
+ includeNodeSpans: 'N8N_OTEL_TRACES_INCLUDE_NODE_SPANS',
24
+ injectOutbound: 'N8N_OTEL_TRACES_INJECT_OUTBOUND',
25
+ productionExecutionsOnly: 'N8N_OTEL_TRACES_PRODUCTION_ONLY',
26
+ } as const;
@@ -0,0 +1,75 @@
1
+ import type { Scope } from '@n8n/permissions';
2
+ import { useRBACStore } from '@n8n/stores/rbac.store';
3
+ import { createPinia, setActivePinia } from 'pinia';
4
+
5
+ import { OTEL_SETTINGS_VIEW } from './otel.constants';
6
+ import { OtelModule } from './otel.module';
7
+
8
+ /**
9
+ * Guards the descriptor half of the shell-to-descriptor move of the otel settings
10
+ * sidebar item.
11
+ *
12
+ * The old gate lived in the shell's `useSettingsItems.ts` as
13
+ * `isModuleActive('otel') && hasPermission(['rbac'], { rbac: { scope: 'otel:manage' } })`.
14
+ * It is now split: the descriptor's `available` getter owns the scope half, which is
15
+ * what this file covers, and `ui.store`'s `settingsSidebarItems` owns the
16
+ * module-active half, covered by `ui.store.settingsPages.test.ts` in the shell.
17
+ */
18
+ describe('OtelModule', () => {
19
+ const settingsPage = () =>
20
+ OtelModule.settingsPages?.find((item) => item.id === 'settings-opentelemetry');
21
+
22
+ const withScopes = (scopes: Scope[]) => {
23
+ useRBACStore().setGlobalScopes(scopes);
24
+ return settingsPage();
25
+ };
26
+
27
+ beforeEach(() => {
28
+ setActivePinia(createPinia());
29
+ });
30
+
31
+ describe('settings sidebar item', () => {
32
+ it('should hide the item from a user without the otel:manage scope', () => {
33
+ expect(withScopes([])?.available).toBe(false);
34
+ });
35
+
36
+ it('should hide the item from a user holding only an unrelated scope', () => {
37
+ expect(withScopes(['workflow:read'])?.available).toBe(false);
38
+ });
39
+
40
+ it('should show the item to a user with the otel:manage scope', () => {
41
+ expect(withScopes(['otel:manage'])?.available).toBe(true);
42
+ });
43
+
44
+ it('should re-evaluate availability when scopes change after registration', () => {
45
+ const item = withScopes([]);
46
+ expect(item?.available).toBe(false);
47
+
48
+ useRBACStore().addGlobalScope('otel:manage');
49
+
50
+ expect(item?.available).toBe(true);
51
+ });
52
+ });
53
+
54
+ describe('route', () => {
55
+ it('should keep routing to the unchanged SettingsOpenTelemetryView route name', () => {
56
+ expect(OTEL_SETTINGS_VIEW).toBe('SettingsOpenTelemetryView');
57
+ expect(settingsPage()?.route).toEqual({ to: { name: 'SettingsOpenTelemetryView' } });
58
+ expect(OtelModule.routes?.[0]).toMatchObject({
59
+ path: 'opentelemetry',
60
+ name: 'SettingsOpenTelemetryView',
61
+ });
62
+ });
63
+
64
+ it('should keep the route rbac middleware, which gates direct URL access', () => {
65
+ expect(OtelModule.routes?.[0].meta).toMatchObject({
66
+ middleware: ['authenticated', 'rbac', 'custom'],
67
+ middlewareOptions: { rbac: { scope: 'otel:manage' } },
68
+ });
69
+ });
70
+
71
+ it('should load the view lazily, so the shell does not pull it in at boot', () => {
72
+ expect(typeof OtelModule.routes?.[0].component).toBe('function');
73
+ });
74
+ });
75
+ });
@@ -0,0 +1,52 @@
1
+ import type { FrontendModuleDescription } from '@n8n/frontend-module-sdk';
2
+ import { useI18n } from '@n8n/i18n';
3
+ import { useRBACStore } from '@n8n/stores/rbac.store';
4
+
5
+ import { OTEL_SETTINGS_VIEW } from './otel.constants';
6
+
7
+ const i18n = useI18n();
8
+
9
+ // typescript-eslint reads an SFC import as `any`, because only vue-tsc can type one.
10
+ // `pnpm turbo typecheck` is what checks this component for real.
11
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
12
+ const SettingsOpenTelemetryView = async () => await import('./SettingsOpenTelemetryView.vue');
13
+
14
+ export const OtelModule: FrontendModuleDescription = {
15
+ id: 'otel',
16
+ name: 'OpenTelemetry',
17
+ description: 'Configure OpenTelemetry settings',
18
+ icon: 'telescope',
19
+ routes: [
20
+ {
21
+ path: 'opentelemetry',
22
+ name: OTEL_SETTINGS_VIEW,
23
+ component: SettingsOpenTelemetryView,
24
+ meta: {
25
+ layout: 'settings',
26
+ middleware: ['authenticated', 'rbac', 'custom'],
27
+ middlewareOptions: {
28
+ rbac: {
29
+ scope: 'otel:manage',
30
+ },
31
+ },
32
+ telemetry: {
33
+ pageCategory: 'settings',
34
+ },
35
+ },
36
+ },
37
+ ],
38
+ settingsPages: [
39
+ {
40
+ id: 'settings-opentelemetry',
41
+ icon: 'telescope',
42
+ label: i18n.baseText('settings.opentelemetry'),
43
+ position: 'top',
44
+ route: { to: { name: OTEL_SETTINGS_VIEW } },
45
+ // Getter, not a value: the item is registered once at init, but the
46
+ // scope check must re-run whenever the sidebar recomputes.
47
+ get available() {
48
+ return useRBACStore().hasScope('otel:manage');
49
+ },
50
+ },
51
+ ],
52
+ };