diff --git a/src/subdomains/generic/gs/__tests__/gs.service.spec.ts b/src/subdomains/generic/gs/__tests__/gs.service.spec.ts index 099248ccf9..331ded6eef 100644 --- a/src/subdomains/generic/gs/__tests__/gs.service.spec.ts +++ b/src/subdomains/generic/gs/__tests__/gs.service.spec.ts @@ -2,7 +2,10 @@ import { BadRequestException } from '@nestjs/common'; import { createMock } from '@golevelup/ts-jest'; import { DataSource } from 'typeorm'; import { AppInsightsQueryService } from 'src/integration/infrastructure/app-insights-query.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; import { GsService } from '../gs.service'; +import { DebugLogQueryTemplates, LogQueryAuditPrefix } from '../dto/gs.dto'; +import { LogQueryTemplate } from '../dto/log-query.dto'; import { UserDataService } from '../../user/models/user-data/user-data.service'; import { UserService } from '../../user/models/user/user.service'; import { BuyService } from 'src/subdomains/core/buy-crypto/routes/buy/buy.service'; @@ -27,12 +30,14 @@ import { VirtualIbanService } from 'src/subdomains/supporting/bank/virtual-iban/ describe('GsService', () => { let service: GsService; let dataSource: DataSource; + let appInsightsQueryService: AppInsightsQueryService; beforeEach(() => { dataSource = createMock(); + appInsightsQueryService = createMock(); service = new GsService( - createMock(), + appInsightsQueryService, createMock(), createMock(), createMock(), @@ -192,4 +197,27 @@ describe('GsService', () => { }); }); }); + + describe('LogQueryAuditPrefix sync', () => { + it('ALL_TRACES template excludes the exact audit prefix that gs.service emits', async () => { + const verboseSpy = jest.spyOn(DfxLogger.prototype, 'verbose').mockImplementation(() => undefined); + jest.spyOn(appInsightsQueryService, 'query').mockResolvedValue({ tables: [{ columns: [], rows: [] }] } as never); + + await service.executeLogQuery({ template: LogQueryTemplate.ALL_TRACES, hours: 1 }, '0xtester'); + + // 1) The service emits an audit log that starts with LogQueryAuditPrefix + const emitted = verboseSpy.mock.calls.map((args) => String(args[0])).join('\n'); + expect(emitted).toContain(`${LogQueryAuditPrefix}0xtester`); + expect(emitted.startsWith(LogQueryAuditPrefix)).toBe(true); + + // 2) The ALL_TRACES template KQL excludes lines with that exact prefix + // (after DfxLogger's "[GsService] " class-context prefix). This binds + // service and template via the shared constant — refactoring the + // constant will update both sides at once. + const kql = DebugLogQueryTemplates[LogQueryTemplate.ALL_TRACES].kql; + expect(kql).toContain(`[GsService] ${LogQueryAuditPrefix}`); + + verboseSpy.mockRestore(); + }); + }); }); diff --git a/src/subdomains/generic/gs/dto/gs.dto.ts b/src/subdomains/generic/gs/dto/gs.dto.ts index 62afe96b02..83a819becf 100644 --- a/src/subdomains/generic/gs/dto/gs.dto.ts +++ b/src/subdomains/generic/gs/dto/gs.dto.ts @@ -7,6 +7,18 @@ export const GsRestrictedColumns: Record = { asset: ['ikna'], }; +/** + * Prefix of the verbose audit message emitted by `gs.service.executeLogQuery` + * (`[GsService] Log query by ...`). The ALL_TRACES template excludes lines + * with this prefix to prevent recursive self-match for high-frequency + * callers. Keep service and template in sync via this constant. + * + * Note: the leading `[GsService] ` is prepended by `DfxLogger` from the + * `GsService` class name, not from this constant. Renaming the service + * class would break the KQL filter silently — no test covers that path. + */ +export const LogQueryAuditPrefix = 'Log query by '; + // Debug endpoint export const DebugMaxResults = 10000; export const DebugBlockedSchemas = ['sys', 'information_schema', 'master', 'msdb', 'tempdb', 'pg_catalog', 'pg_toast']; @@ -182,6 +194,22 @@ export const DebugLogQueryTemplates: Record< requiredParams: ['eventName'], defaultLimit: 500, }, + [LogQueryTemplate.ALL_TRACES]: { + // Returns all trace entries in the given window. Self-emitted audit lines + // from /gs/debug/logs (start with "[GsService] " + LogQueryAuditPrefix) + // are filtered out at the source so they don't crowd the result for + // high-frequency dashboard callers. The "[GsService] " prefix is added by + // DfxLogger's class-context; LogQueryAuditPrefix is the message prefix + // emitted by gs.service.executeLogQuery — both sides reference the same + // constant to keep service and template in sync. + kql: `traces +| where timestamp > ago({hours}h) +| where not(message startswith "[GsService] ${LogQueryAuditPrefix}") +| project timestamp, severityLevel, message, operation_Id +| order by timestamp desc`, + requiredParams: [], + defaultLimit: 500, + }, }; // Support endpoint diff --git a/src/subdomains/generic/gs/dto/log-query.dto.ts b/src/subdomains/generic/gs/dto/log-query.dto.ts index 5067f783d4..eaa5e9ef9e 100644 --- a/src/subdomains/generic/gs/dto/log-query.dto.ts +++ b/src/subdomains/generic/gs/dto/log-query.dto.ts @@ -7,6 +7,7 @@ export enum LogQueryTemplate { REQUEST_FAILURES = 'request-failures', DEPENDENCIES_SLOW = 'dependencies-slow', CUSTOM_EVENTS = 'custom-events', + ALL_TRACES = 'all-traces', } export class LogQueryDto { diff --git a/src/subdomains/generic/gs/gs.service.ts b/src/subdomains/generic/gs/gs.service.ts index 8a334dec72..ccf2610590 100644 --- a/src/subdomains/generic/gs/gs.service.ts +++ b/src/subdomains/generic/gs/gs.service.ts @@ -37,6 +37,7 @@ import { DebugMaxResults, GsRestrictedColumns, GsRestrictedMarker, + LogQueryAuditPrefix, SupportTable, } from './dto/gs.dto'; import { LogQueryDto, LogQueryResult } from './dto/log-query.dto'; @@ -285,7 +286,9 @@ export class GsService { kql += `\n| take ${template.defaultLimit}`; // Log for audit - this.logger.verbose(`Log query by ${userIdentifier}: template=${dto.template}, params=${JSON.stringify(dto)}`); + this.logger.verbose( + `${LogQueryAuditPrefix}${userIdentifier}: template=${dto.template}, params=${JSON.stringify(dto)}`, + ); // Execute const timespan = `PT${dto.hours ?? 1}H`; @@ -302,7 +305,7 @@ export class GsService { rows: response.tables[0].rows, }; } catch (e) { - this.logger.info(`Log query by ${userIdentifier} failed: ${e.message}`); + this.logger.info(`${LogQueryAuditPrefix}${userIdentifier} failed: ${e.message}`); throw new BadRequestException('Query execution failed'); } }