From 3dfca93a37a6c91370182732fd4134ebf9416efb Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Mon, 1 Jun 2026 10:29:14 +0200 Subject: [PATCH] feat(core): Add telemetry events for private credentials (no-changelog) (#31343) --- .../oauth1-credential.controller.test.ts | 50 ++++++ .../oauth2-credential.controller.test.ts | 16 +- .../oauth/oauth1-credential.controller.ts | 11 ++ .../oauth/oauth2-credential.controller.ts | 11 ++ .../__tests__/credentials.controller.test.ts | 163 ++++++++++++++++++ .../src/credentials/credentials.controller.ts | 34 ++++ .../__tests__/telemetry-event-relay.test.ts | 113 ++++++++++++ .../cli/src/events/maps/relay.event-map.ts | 32 ++++ .../events/relays/telemetry.event-relay.ts | 76 ++++++++ 9 files changed, 504 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts index 9123f0a7047..b2898250c08 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth1-credential.controller.test.ts @@ -6,6 +6,7 @@ import { mock } from 'jest-mock-extended'; import axios from 'axios'; import type { Response } from 'express'; import { OAuth1CredentialController } from '@/controllers/oauth/oauth1-credential.controller'; +import { EventService } from '@/events/event.service'; import type { OAuthRequest } from '@/requests'; import { OauthService } from '@/oauth/oauth.service'; import { ExternalHooks } from '@/external-hooks'; @@ -14,6 +15,7 @@ jest.mock('axios'); describe('OAuth1CredentialController', () => { const oauthService = mockInstance(OauthService); + const eventService = mockInstance(EventService); mockInstance(Logger); mockInstance(ExternalHooks); @@ -168,10 +170,58 @@ describe('OAuth1CredentialController', () => { 'resolver-id', {}, ); + expect(eventService.emit).not.toHaveBeenCalledWith( + 'private-credential-user-connected', + expect.anything(), + ); expect(oauthService.encryptAndSaveData).not.toHaveBeenCalled(); expect(res.render).toHaveBeenCalledWith('oauth-callback'); }); + it('should emit "private-credential-user-connected" when state.userId is a string', async () => { + const mockResolvedCredential = mock({ + id: 'cred-1', + type: 'twitterOAuth1Api', + }); + const mockState = { + token: 'token', + cid: '1', + userId: 'user-42', + origin: 'dynamic-credential' as const, + credentialResolverId: 'resolver-id', + authorizationHeader: 'Bearer token123', + createdAt: timestamp, + data: 'encrypted-data', + }; + const dynamicState = Buffer.from(JSON.stringify(mockState)).toString('base64'); + const dynamicReq = mock({ + query: { + oauth_verifier: 'verifier', + oauth_token: 'token', + state: dynamicState, + }, + }); + + oauthService.resolveCredential.mockResolvedValueOnce([ + mockResolvedCredential, + { csrfSecret: 'invalid' }, + { accessTokenUrl: 'https://example.domain/oauth/access_token' }, + mockState, + ]); + jest + .mocked(axios.post) + .mockResolvedValueOnce({ data: 'oauth_token=token&oauth_token_secret=secret' } as any); + oauthService.saveDynamicCredential.mockResolvedValueOnce(undefined); + + await controller.handleCallback(dynamicReq, res); + + expect(eventService.emit).toHaveBeenCalledWith('private-credential-user-connected', { + user: { id: 'user-42' }, + credentialType: 'twitterOAuth1Api', + credentialId: 'cred-1', + }); + }); + it('should render error when credentialResolverId is missing for dynamic credential', async () => { const mockResolvedCredential = mock({ id: '1' }); const mockState = { diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts index 2d97f6d997f..c669e3117c7 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts @@ -6,6 +6,7 @@ import { mock } from 'jest-mock-extended'; import type { Response } from 'express'; import { UserError } from 'n8n-workflow'; import { OAuth2CredentialController } from '@/controllers/oauth/oauth2-credential.controller'; +import { EventService } from '@/events/event.service'; import type { OAuthRequest } from '@/requests'; import { OauthService } from '@/oauth/oauth.service'; import { ExternalHooks } from '@/external-hooks'; @@ -19,6 +20,7 @@ describe('OAuth2CredentialController', () => { const oauthService = mockInstance(OauthService); const externalHooks = mockInstance(ExternalHooks); const oauthJweServiceProxy = mockInstance(OAuthJweServiceProxy); + const eventService = mockInstance(EventService); mockInstance(Logger); @@ -158,7 +160,7 @@ describe('OAuth2CredentialController', () => { }) as any, ); - const mockResolvedCredential = mock({ id: '1' }); + const mockResolvedCredential = mock({ id: '1', type: 'genericOAuth2' }); const mockDecryptedData = { csrfSecret: 'csrf-secret', existing: 'data' }; const mockState = { token: 'token', @@ -209,6 +211,11 @@ describe('OAuth2CredentialController', () => { 'resolver-id', {}, ); + expect(eventService.emit).toHaveBeenCalledWith('private-credential-user-connected', { + user: { id: '123' }, + credentialType: 'genericOAuth2', + credentialId: '1', + }); expect(oauthService.encryptAndSaveData).not.toHaveBeenCalled(); expect(res.render).toHaveBeenCalledWith('oauth-callback'); }); @@ -830,7 +837,7 @@ describe('OAuth2CredentialController', () => { jest .mocked(ClientOAuth2) .mockImplementation(() => ({ code: { getToken: mockGetToken } }) as any); - const mockResolvedCredential = mock({ id: '1' }); + const mockResolvedCredential = mock({ id: '1', type: 'genericOAuth2' }); oauthService.resolveCredential.mockResolvedValueOnce([ mockResolvedCredential, { csrfSecret: 'csrf-secret' }, @@ -872,6 +879,11 @@ describe('OAuth2CredentialController', () => { 'resolver-1', { tenant: 'acme' }, ); + expect(eventService.emit).toHaveBeenCalledWith('private-credential-user-connected', { + user: { id: '123' }, + credentialType: 'genericOAuth2', + credentialId: '1', + }); expect(oauthService.encryptAndSaveData).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts b/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts index b8c8d5bacca..d3eca6b37ce 100644 --- a/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts +++ b/packages/cli/src/controllers/oauth/oauth1-credential.controller.ts @@ -4,6 +4,7 @@ import axios from 'axios'; import { Response } from 'express'; import { ensureError, jsonStringify } from 'n8n-workflow'; +import { EventService } from '@/events/event.service'; import { OAuthRequest } from '@/requests'; import { OauthService, type OAuth1CredentialData } from '@/oauth/oauth.service'; @@ -13,6 +14,7 @@ export class OAuth1CredentialController { constructor( private readonly oauthService: OauthService, private readonly logger: Logger, + private readonly eventService: EventService, ) {} /** Get Authorization url */ @@ -89,6 +91,15 @@ export class OAuth1CredentialController { state.credentialResolverId, (state.authMetadata as Record) ?? {}, ); + + if (typeof state.userId === 'string') { + this.eventService.emit('private-credential-user-connected', { + user: { id: state.userId }, + credentialType: credential.type, + credentialId: credential.id, + }); + } + return res.render('oauth-callback'); } } catch (e) { diff --git a/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts b/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts index 51ceecf3b24..1366961722e 100644 --- a/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts +++ b/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts @@ -9,6 +9,7 @@ import split from 'lodash/split'; import type { ICredentialDataDecryptedObject, IDataObject } from 'n8n-workflow'; import { ensureError, jsonParse, jsonStringify } from 'n8n-workflow'; +import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { OAuthJweServiceProxy } from '@/oauth/oauth-jwe-service.proxy'; import { OauthService, OauthVersion } from '@/oauth/oauth.service'; @@ -21,6 +22,7 @@ export class OAuth2CredentialController { private readonly logger: Logger, private readonly externalHooks: ExternalHooks, private readonly oauthJweServiceProxy: OAuthJweServiceProxy, + private readonly eventService: EventService, ) {} /** Get Authorization url */ @@ -133,6 +135,15 @@ export class OAuth2CredentialController { state.credentialResolverId, (state.authMetadata as Record) ?? {}, ); + + if (typeof state.userId === 'string') { + this.eventService.emit('private-credential-user-connected', { + user: { id: state.userId }, + credentialType: credential.type, + credentialId: credential.id, + }); + } + return res.render('oauth-callback'); } } catch (e) { diff --git a/packages/cli/src/credentials/__tests__/credentials.controller.test.ts b/packages/cli/src/credentials/__tests__/credentials.controller.test.ts index 7c6f65f8697..1a74de6250b 100644 --- a/packages/cli/src/credentials/__tests__/credentials.controller.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.controller.test.ts @@ -175,6 +175,45 @@ describe('CredentialsController', () => { expect(eventName).toBe('credentials-created'); expect(eventPayload).toMatchObject({ jweEnabled: true }); }); + + it('should emit "private-credential-created" when credential is created as resolvable', async () => { + const newCredentialsPayload = createNewCredentialsPayload(); + req.body = newCredentialsPayload; + const { data, ...payloadWithoutData } = newCredentialsPayload; + const createdCredentials = createdCredentialsWithScopes({ + ...payloadWithoutData, + isResolvable: true, + }); + const project = mock({ id: 'p1', type: 'team' }); + + createUnmanagedCredentialSpy.mockResolvedValue(createdCredentials); + findCredentialOwningProjectSpy.mockResolvedValue(project); + + await credentialsController.createCredentials(req, res, newCredentialsPayload); + + expect(emitSpy).toHaveBeenCalledWith('private-credential-created', { + user: req.user, + credentialType: createdCredentials.type, + credentialId: createdCredentials.id, + projectId: project.id, + projectType: project.type, + }); + }); + + it('should not emit "private-credential-created" when credential is not resolvable', async () => { + const newCredentialsPayload = createNewCredentialsPayload(); + req.body = newCredentialsPayload; + const { data, ...payloadWithoutData } = newCredentialsPayload; + const createdCredentials = createdCredentialsWithScopes(payloadWithoutData); + + createUnmanagedCredentialSpy.mockResolvedValue(createdCredentials); + findCredentialOwningProjectSpy.mockResolvedValue(mock()); + + await credentialsController.createCredentials(req, res, newCredentialsPayload); + + const emittedEventNames = emitSpy.mock.calls.map((call) => call[0]); + expect(emittedEventNames).not.toContain('private-credential-created'); + }); }); describe('updateCredentials', () => { @@ -584,5 +623,129 @@ describe('CredentialsController', () => { }); expect(updateSpy).not.toHaveBeenCalled(); }); + + it('should emit "private-credential-toggled-to-private" when toggling static to private', async () => { + const ownerReq = { + user: { id: 'owner-id', role: GLOBAL_OWNER_ROLE }, + params: { credentialId }, + body: { data: { apiKey: 'k' }, isResolvable: true }, + } as unknown as CredentialRequest.Update; + + const staticCredential = mock({ + ...existingCredential, + isResolvable: false, + }); + + credentialsFinderService.findCredentialForUser.mockResolvedValue(staticCredential); + createEncryptedDataSpy.mockResolvedValue(getEncryptedCredential(true)); + updateSpy.mockResolvedValue({ ...staticCredential, isResolvable: true }); + + await credentialsController.updateCredentials(ownerReq); + + expect(emitSpy).toHaveBeenCalledWith('private-credential-toggled-to-private', { + user: ownerReq.user, + credentialType: staticCredential.type, + credentialId: staticCredential.id, + }); + const emittedEventNames = emitSpy.mock.calls.map((call) => call[0]); + expect(emittedEventNames).not.toContain('private-credential-toggled-to-static'); + }); + + it('should emit "private-credential-toggled-to-static" when toggling private to static', async () => { + const ownerReq = { + user: { id: 'owner-id', role: GLOBAL_OWNER_ROLE }, + params: { credentialId }, + body: { data: { apiKey: 'k' }, isResolvable: false }, + } as unknown as CredentialRequest.Update; + + const privateCredential = mock({ + ...existingCredential, + isResolvable: true, + }); + + credentialsFinderService.findCredentialForUser.mockResolvedValue(privateCredential); + createEncryptedDataSpy.mockResolvedValue(getEncryptedCredential(false)); + updateSpy.mockResolvedValue({ ...privateCredential, isResolvable: false }); + + await credentialsController.updateCredentials(ownerReq); + + expect(emitSpy).toHaveBeenCalledWith('private-credential-toggled-to-static', { + user: ownerReq.user, + credentialType: privateCredential.type, + credentialId: privateCredential.id, + }); + const emittedEventNames = emitSpy.mock.calls.map((call) => call[0]); + expect(emittedEventNames).not.toContain('private-credential-toggled-to-private'); + }); + + it('should not emit toggle events when resolvable state is unchanged', async () => { + const ownerReq = { + user: { id: 'owner-id', role: GLOBAL_OWNER_ROLE }, + params: { credentialId }, + body: { data: { apiKey: 'k' }, isResolvable: true }, + } as unknown as CredentialRequest.Update; + + const privateCredential = mock({ + ...existingCredential, + isResolvable: true, + }); + + credentialsFinderService.findCredentialForUser.mockResolvedValue(privateCredential); + createEncryptedDataSpy.mockResolvedValue(getEncryptedCredential(true)); + updateSpy.mockResolvedValue({ ...privateCredential, isResolvable: true }); + + await credentialsController.updateCredentials(ownerReq); + + const emittedEventNames = emitSpy.mock.calls.map((call) => call[0]); + expect(emittedEventNames).not.toContain('private-credential-toggled-to-private'); + expect(emittedEventNames).not.toContain('private-credential-toggled-to-static'); + }); + }); + + describe('deleteCredentials', () => { + const credentialId = 'cred-del-1'; + + it('should emit "private-credential-deleted" when deleting a resolvable credential', async () => { + const privateCredential = mock({ + id: credentialId, + type: 'gmailOAuth2', + isResolvable: true, + }); + credentialsFinderService.findCredentialForUser.mockResolvedValue(privateCredential); + jest.spyOn(credentialsService, 'delete').mockResolvedValue(undefined); + + const deleteReq = { + user: { id: 'u1' }, + params: { credentialId }, + } as unknown as CredentialRequest.Delete; + + await credentialsController.deleteCredentials(deleteReq); + + expect(emitSpy).toHaveBeenCalledWith('private-credential-deleted', { + user: deleteReq.user, + credentialType: privateCredential.type, + credentialId: privateCredential.id, + }); + }); + + it('should not emit "private-credential-deleted" when deleting a static credential', async () => { + const staticCredential = mock({ + id: credentialId, + type: 'gmailOAuth2', + isResolvable: false, + }); + credentialsFinderService.findCredentialForUser.mockResolvedValue(staticCredential); + jest.spyOn(credentialsService, 'delete').mockResolvedValue(undefined); + + const deleteReq = { + user: { id: 'u1' }, + params: { credentialId }, + } as unknown as CredentialRequest.Delete; + + await credentialsController.deleteCredentials(deleteReq); + + const emittedEventNames = emitSpy.mock.calls.map((call) => call[0]); + expect(emittedEventNames).not.toContain('private-credential-deleted'); + }); }); }); diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 7774cd3ff0a..a2967b8eec3 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -179,6 +179,16 @@ export class CredentialsController { jweEnabled: payload.data.jweEnabled === true, }); + if (newCredential.isResolvable) { + this.eventService.emit('private-credential-created', { + user: req.user, + credentialType: newCredential.type, + credentialId: newCredential.id, + projectId: project?.id, + projectType: project?.type, + }); + } + return newCredential; } @@ -279,6 +289,22 @@ export class CredentialsController { true, }); + const wasResolvable = Boolean(credential.isResolvable); + const willBeResolvable = Boolean(newCredentialData.isResolvable); + if (!wasResolvable && willBeResolvable) { + this.eventService.emit('private-credential-toggled-to-private', { + user: req.user, + credentialType: credential.type, + credentialId: credential.id, + }); + } else if (wasResolvable && !willBeResolvable) { + this.eventService.emit('private-credential-toggled-to-static', { + user: req.user, + credentialType: credential.type, + credentialId: credential.id, + }); + } + const scopes = await this.credentialsService.getCredentialScopes(req.user, credential.id); return { ...rest, scopes }; @@ -313,6 +339,14 @@ export class CredentialsController { credentialId: credential.id, }); + if (credential.isResolvable) { + this.eventService.emit('private-credential-deleted', { + user: req.user, + credentialType: credential.type, + credentialId: credential.id, + }); + } + return true; } diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index 3c0e81a04bb..59c64b42fc9 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -993,6 +993,119 @@ describe('TelemetryEventRelay', () => { credential_id: 'cred123', }); }); + + it('should track on `private-credential-created` event', () => { + const event: RelayEventMap['private-credential-created'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: { slug: GLOBAL_OWNER_ROLE.slug }, + }, + credentialId: 'cred123', + credentialType: 'gmailOAuth2', + projectId: 'project456', + projectType: 'personal', + }; + + eventService.emit('private-credential-created', event); + + expect(telemetry.track).toHaveBeenCalledWith('User created private credential', { + user_id: 'user123', + user_role: GLOBAL_OWNER_ROLE.slug, + credential_type: 'gmailOAuth2', + credential_id: 'cred123', + project_id: 'project456', + project_type: 'personal', + }); + }); + + it('should track on `private-credential-toggled-to-private` event', () => { + const event: RelayEventMap['private-credential-toggled-to-private'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: { slug: GLOBAL_OWNER_ROLE.slug }, + }, + credentialId: 'cred123', + credentialType: 'gmailOAuth2', + }; + + eventService.emit('private-credential-toggled-to-private', event); + + expect(telemetry.track).toHaveBeenCalledWith('User made credential private', { + user_id: 'user123', + user_role: GLOBAL_OWNER_ROLE.slug, + credential_type: 'gmailOAuth2', + credential_id: 'cred123', + }); + }); + + it('should track on `private-credential-toggled-to-static` event', () => { + const event: RelayEventMap['private-credential-toggled-to-static'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: { slug: GLOBAL_OWNER_ROLE.slug }, + }, + credentialId: 'cred123', + credentialType: 'gmailOAuth2', + }; + + eventService.emit('private-credential-toggled-to-static', event); + + expect(telemetry.track).toHaveBeenCalledWith('User made credential static', { + user_id: 'user123', + user_role: GLOBAL_OWNER_ROLE.slug, + credential_type: 'gmailOAuth2', + credential_id: 'cred123', + }); + }); + + it('should track on `private-credential-deleted` event', () => { + const event: RelayEventMap['private-credential-deleted'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: { slug: GLOBAL_OWNER_ROLE.slug }, + }, + credentialId: 'cred123', + credentialType: 'gmailOAuth2', + }; + + eventService.emit('private-credential-deleted', event); + + expect(telemetry.track).toHaveBeenCalledWith('User deleted private credential', { + user_id: 'user123', + user_role: GLOBAL_OWNER_ROLE.slug, + credential_type: 'gmailOAuth2', + credential_id: 'cred123', + }); + }); + + it('should track on `private-credential-user-connected` event', () => { + const event: RelayEventMap['private-credential-user-connected'] = { + user: { id: 'user123' }, + credentialId: 'cred123', + credentialType: 'gmailOAuth2', + }; + + eventService.emit('private-credential-user-connected', event); + + expect(telemetry.track).toHaveBeenCalledWith('User connected to private credential', { + user_id: 'user123', + user_role: undefined, + credential_type: 'gmailOAuth2', + credential_id: 'cred123', + }); + }); }); describe('LDAP events', () => { diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 5c2d88ba96d..b143c359397 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -436,6 +436,38 @@ export type RelayEventMap = { credentialId: string; }; + 'private-credential-created': { + user: UserLike; + credentialType: string; + credentialId: string; + projectId?: string; + projectType?: string; + }; + + 'private-credential-toggled-to-private': { + user: UserLike; + credentialType: string; + credentialId: string; + }; + + 'private-credential-toggled-to-static': { + user: UserLike; + credentialType: string; + credentialId: string; + }; + + 'private-credential-deleted': { + user: UserLike; + credentialType: string; + credentialId: string; + }; + + 'private-credential-user-connected': { + user: UserLike; + credentialType: string; + credentialId: string; + }; + // #endregion // #region Community package diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index b37aacf9137..c8dc9948c1d 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -128,6 +128,13 @@ export class TelemetryEventRelay extends EventRelay { 'credentials-updated': (event) => this.credentialsUpdated(event), 'credentials-deleted': (event) => this.credentialsDeleted(event), 'credentials-user-disconnected': (event) => this.credentialsUserDisconnected(event), + 'private-credential-created': (event) => this.privateCredentialCreated(event), + 'private-credential-toggled-to-private': (event) => + this.privateCredentialToggledToPrivate(event), + 'private-credential-toggled-to-static': (event) => + this.privateCredentialToggledToStatic(event), + 'private-credential-deleted': (event) => this.privateCredentialDeleted(event), + 'private-credential-user-connected': (event) => this.privateCredentialUserConnected(event), 'ldap-general-sync-finished': (event) => this.ldapGeneralSyncFinished(event), 'ldap-settings-updated': (event) => this.ldapSettingsUpdated(event), 'ldap-login-sync-failed': (event) => this.ldapLoginSyncFailed(event), @@ -637,6 +644,75 @@ export class TelemetryEventRelay extends EventRelay { }); } + private privateCredentialCreated({ + user, + credentialId, + credentialType, + projectId, + projectType, + }: RelayEventMap['private-credential-created']) { + this.telemetry.track('User created private credential', { + user_id: user.id, + user_role: user.role?.slug, + credential_type: credentialType, + credential_id: credentialId, + project_id: projectId, + project_type: projectType, + }); + } + + private privateCredentialToggledToPrivate({ + user, + credentialId, + credentialType, + }: RelayEventMap['private-credential-toggled-to-private']) { + this.telemetry.track('User made credential private', { + user_id: user.id, + user_role: user.role?.slug, + credential_type: credentialType, + credential_id: credentialId, + }); + } + + private privateCredentialToggledToStatic({ + user, + credentialId, + credentialType, + }: RelayEventMap['private-credential-toggled-to-static']) { + this.telemetry.track('User made credential static', { + user_id: user.id, + user_role: user.role?.slug, + credential_type: credentialType, + credential_id: credentialId, + }); + } + + private privateCredentialDeleted({ + user, + credentialId, + credentialType, + }: RelayEventMap['private-credential-deleted']) { + this.telemetry.track('User deleted private credential', { + user_id: user.id, + user_role: user.role?.slug, + credential_type: credentialType, + credential_id: credentialId, + }); + } + + private privateCredentialUserConnected({ + user, + credentialId, + credentialType, + }: RelayEventMap['private-credential-user-connected']) { + this.telemetry.track('User connected to private credential', { + user_id: user.id, + user_role: user.role?.slug, + credential_type: credentialType, + credential_id: credentialId, + }); + } + // #endregion // #region LDAP