Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1005,6 +1005,9 @@ export class Configuration {
?.replace('BlobEndpoint=', ''),
connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
},
appInsights: {
appId: process.env.APPINSIGHTS_APP_ID,
},
};

alby = {
Expand Down
81 changes: 81 additions & 0 deletions src/integration/infrastructure/app-insights-query.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { Config } from 'src/config/config';
import { DfxLogger } from 'src/shared/services/dfx-logger';
import { HttpService } from 'src/shared/services/http.service';

interface AppInsightsQueryResponse {
tables: {
name: string;
columns: { name: string; type: string }[];
rows: unknown[][];
}[];
}

@Injectable()
export class AppInsightsQueryService {
private readonly logger = new DfxLogger(AppInsightsQueryService);

private readonly baseUrl = 'https://api.applicationinsights.io/v1';
private readonly TOKEN_REFRESH_BUFFER_MS = 60000;

private accessToken: string | null = null;
private tokenExpiresAt = 0;

constructor(private readonly http: HttpService) {}

async query(kql: string, timespan?: string): Promise<AppInsightsQueryResponse> {
const appId = Config.azure.appInsights?.appId;
if (!appId) {
throw new Error('App Insights App ID not configured');
}

const body: { query: string; timespan?: string } = { query: kql };
if (timespan) body.timespan = timespan;

return this.request<AppInsightsQueryResponse>(`apps/${appId}/query`, body);
}

private async request<T>(url: string, body: object, nthTry = 3): Promise<T> {
try {
if (!this.accessToken || Date.now() >= this.tokenExpiresAt - this.TOKEN_REFRESH_BUFFER_MS) {
await this.refreshAccessToken();
}

return await this.http.request<T>({
url: `${this.baseUrl}/${url}`,
method: 'POST',
data: body,
headers: {
Authorization: `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
},
});
} catch (e) {
if (nthTry > 1 && e.response?.status === 401) {
await this.refreshAccessToken();
return this.request(url, body, nthTry - 1);
}
throw e;
}
}

private async refreshAccessToken(): Promise<void> {
try {
const { access_token, expires_in } = await this.http.post<{ access_token: string; expires_in: number }>(
`https://login.microsoftonline.com/${Config.azure.tenantId}/oauth2/token`,
new URLSearchParams({
grant_type: 'client_credentials',
client_id: Config.azure.clientId,
client_secret: Config.azure.clientSecret,
resource: 'https://api.applicationinsights.io',
}),
);

this.accessToken = access_token;
this.tokenExpiresAt = Date.now() + expires_in * 1000;
} catch (e) {
this.logger.error('Failed to refresh App Insights access token', e);
throw new Error('Failed to authenticate with App Insights');
}
}
}
4 changes: 3 additions & 1 deletion src/integration/integration.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { BlockchainModule } from './blockchain/blockchain.module';
import { CheckoutModule } from './checkout/checkout.module';
import { ExchangeModule } from './exchange/exchange.module';
import { IknaModule } from './ikna/ikna.module';
import { AppInsightsQueryService } from './infrastructure/app-insights-query.service';
import { AzureService } from './infrastructure/azure-service';
import { LetterModule } from './letter/letter.module';
import { SiftModule } from './sift/sift.module';
Expand All @@ -21,7 +22,7 @@ import { SiftModule } from './sift/sift.module';
SiftModule,
],
controllers: [],
providers: [AzureService],
providers: [AzureService, AppInsightsQueryService],
exports: [
BankIntegrationModule,
BlockchainModule,
Expand All @@ -30,6 +31,7 @@ import { SiftModule } from './sift/sift.module';
IknaModule,
CheckoutModule,
AzureService,
AppInsightsQueryService,
SiftModule,
],
})
Expand Down
2 changes: 2 additions & 0 deletions src/shared/services/http.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const MOCK_RESPONSES: { pattern: RegExp; response: any }[] = [
bic_candidates: [{ bic: 'MOCKBIC1XXX' }],
},
},
{ pattern: /login\.microsoftonline\.com/, response: { access_token: 'mock-token', expires_in: 3600 } },
{ pattern: /api\.applicationinsights\.io/, response: { tables: [{ name: 'PrimaryResult', columns: [], rows: [] }] } },
];

@Injectable()
Expand Down
47 changes: 47 additions & 0 deletions src/subdomains/generic/gs/dto/log-query.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { IsEnum, IsInt, IsOptional, IsString, Matches, Max, Min } from 'class-validator';

export enum LogQueryTemplate {
TRACES_BY_OPERATION = 'traces-by-operation',
TRACES_BY_MESSAGE = 'traces-by-message',
EXCEPTIONS_RECENT = 'exceptions-recent',
REQUEST_FAILURES = 'request-failures',
DEPENDENCIES_SLOW = 'dependencies-slow',
CUSTOM_EVENTS = 'custom-events',
}

export class LogQueryDto {
@IsEnum(LogQueryTemplate)
template: LogQueryTemplate;

@IsOptional()
@IsString()
@Matches(/^[a-f0-9-]{36}$/i, { message: 'operationId must be a valid GUID' })
operationId?: string;

@IsOptional()
@IsString()
@Matches(/^[a-zA-Z0-9_\-.: ()]{1,100}$/, { message: 'messageFilter must be alphanumeric with basic punctuation (max 100 chars)' })
messageFilter?: string;

@IsOptional()
@IsInt()
@Min(1)
@Max(168) // max 7 days
hours?: number;

@IsOptional()
@IsInt()
@Min(100)
@Max(5000)
durationMs?: number;

@IsOptional()
@IsString()
@Matches(/^[a-zA-Z0-9_]{1,50}$/, { message: 'eventName must be alphanumeric' })
eventName?: string;
}

export class LogQueryResult {
columns: { name: string; type: string }[];
rows: unknown[][];
}
14 changes: 14 additions & 0 deletions src/subdomains/generic/gs/gs.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { UserRole } from 'src/shared/auth/user-role.enum';
import { DfxLogger } from 'src/shared/services/dfx-logger';
import { DbQueryBaseDto, DbQueryDto, DbReturnData } from './dto/db-query.dto';
import { DebugQueryDto } from './dto/debug-query.dto';
import { LogQueryDto, LogQueryResult } from './dto/log-query.dto';
import { SupportDataQuery, SupportReturnData } from './dto/support-data.dto';
import { GsService } from './gs.service';

Expand Down Expand Up @@ -59,4 +60,17 @@ export class GsController {
throw new BadRequestException(e.message);
}
}

@Post('debug/logs')
@ApiBearerAuth()
@ApiExcludeEndpoint()
@UseGuards(AuthGuard(), RoleGuard(UserRole.DEBUG), UserActiveGuard())
async executeLogQuery(@GetJwt() jwt: JwtPayload, @Body() dto: LogQueryDto): Promise<LogQueryResult> {
try {
return await this.gsService.executeLogQuery(dto, jwt.address ?? `account:${jwt.account}`);
} catch (e) {
this.logger.verbose(`Log query failed:`, e);
throw new BadRequestException(e.message);
}
}
}
2 changes: 2 additions & 0 deletions src/subdomains/generic/gs/gs.module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { BlockchainModule } from 'src/integration/blockchain/blockchain.module';
import { IntegrationModule } from 'src/integration/integration.module';
import { LetterModule } from 'src/integration/letter/letter.module';
import { SharedModule } from 'src/shared/shared.module';
import { BuyCryptoModule } from 'src/subdomains/core/buy-crypto/buy-crypto.module';
Expand All @@ -24,6 +25,7 @@ import { GsService } from './gs.service';
imports: [
SharedModule,
BlockchainModule,
IntegrationModule,
AddressPoolModule,
ReferralModule,
BuyCryptoModule,
Expand Down
Loading