feat: auth metric and trace (#4063)

This commit is contained in:
X1a0t
2023-09-06 12:20:06 +08:00
committed by GitHub
parent d29514c995
commit ef3d3a34e2
9 changed files with 143 additions and 42 deletions

View File

@@ -1,11 +1,53 @@
import { isDesktop } from '@affine/env/constant'; import { isDesktop } from '@affine/env/constant';
import {
generateRandUTF16Chars,
SPAN_ID_BYTES,
TRACE_ID_BYTES,
traceReporter,
} from '@affine/graphql';
import { refreshRootMetadataAtom } from '@affine/workspace/atom'; import { refreshRootMetadataAtom } from '@affine/workspace/atom';
import { getCurrentStore } from '@toeverything/infra/atom'; import { getCurrentStore } from '@toeverything/infra/atom';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports // eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { signIn, signOut } from 'next-auth/react'; import { signIn, signOut } from 'next-auth/react';
import { startTransition } from 'react'; import { startTransition } from 'react';
type TraceParams = {
startTime: string;
spanId: string;
traceId: string;
event: string;
};
function genTraceParams(): TraceParams {
const startTime = new Date().toISOString();
const spanId = generateRandUTF16Chars(SPAN_ID_BYTES);
const traceId = generateRandUTF16Chars(TRACE_ID_BYTES);
const event = 'signInCloud';
return { startTime, spanId, traceId, event };
}
function onResolveHandleTrace<T>(
res: Promise<T> | T,
params: TraceParams
): Promise<T> | T {
const { startTime, spanId, traceId, event } = params;
traceReporter &&
traceReporter.cacheTrace(traceId, spanId, startTime, { event });
return res;
}
function onRejectHandleTrace<T>(
res: Promise<T> | T,
params: TraceParams
): Promise<T> {
const { startTime, spanId, traceId, event } = params;
traceReporter &&
traceReporter.uploadTrace(traceId, spanId, startTime, { event });
return Promise.reject(res);
}
export const signInCloud: typeof signIn = async (provider, ...rest) => { export const signInCloud: typeof signIn = async (provider, ...rest) => {
const traceParams = genTraceParams();
if (isDesktop) { if (isDesktop) {
if (provider === 'google') { if (provider === 'google') {
open( open(
@@ -29,25 +71,32 @@ export const signInCloud: typeof signIn = async (provider, ...rest) => {
callbackUrl: buildCallbackUrl(callbackUrl), callbackUrl: buildCallbackUrl(callbackUrl),
}, },
...tail ...tail
); )
.then(res => onResolveHandleTrace(res, traceParams))
.catch(err => onRejectHandleTrace(err, traceParams));
} }
} else { } else {
return signIn(provider, ...rest); return signIn(provider, ...rest)
.then(res => onResolveHandleTrace(res, traceParams))
.catch(err => onRejectHandleTrace(err, traceParams));
} }
}; };
export const signOutCloud: typeof signOut = async options => { export const signOutCloud: typeof signOut = async options => {
const traceParams = genTraceParams();
return signOut({ return signOut({
...options, ...options,
callbackUrl: '/', callbackUrl: '/',
}).then(result => { })
if (result) { .then(result => {
startTransition(() => { if (result) {
getCurrentStore().set(refreshRootMetadataAtom); startTransition(() => {
}); getCurrentStore().set(refreshRootMetadataAtom);
} });
return result; }
}); return onResolveHandleTrace(result, traceParams);
})
.catch(err => onRejectHandleTrace(err, traceParams));
}; };
export function buildCallbackUrl(callbackUrl: string) { export function buildCallbackUrl(callbackUrl: string) {

View File

@@ -22,4 +22,7 @@ export class Metrics implements OnModuleDestroy {
jwstCodecMerge = metricsCreator.counter('jwst_codec_merge'); jwstCodecMerge = metricsCreator.counter('jwst_codec_merge');
jwstCodecDidnotMatch = metricsCreator.counter('jwst_codec_didnot_match'); jwstCodecDidnotMatch = metricsCreator.counter('jwst_codec_didnot_match');
jwstCodecFail = metricsCreator.counter('jwst_codec_fail'); jwstCodecFail = metricsCreator.counter('jwst_codec_fail');
authCounter = metricsCreator.counter('auth');
authFailCounter = metricsCreator.counter('auth_fail', ['reason']);
} }

View File

@@ -19,6 +19,7 @@ import type { AuthAction, NextAuthOptions } from 'next-auth';
import { AuthHandler } from 'next-auth/core'; import { AuthHandler } from 'next-auth/core';
import { Config } from '../../config'; import { Config } from '../../config';
import { Metrics } from '../../metrics/metrics';
import { PrismaService } from '../../prisma/service'; import { PrismaService } from '../../prisma/service';
import { CloudThrottlerGuard, Throttle } from '../../throttler'; import { CloudThrottlerGuard, Throttle } from '../../throttler';
import { NextAuthOptionsProvide } from './next-auth-options'; import { NextAuthOptionsProvide } from './next-auth-options';
@@ -37,7 +38,8 @@ export class NextAuthController {
readonly prisma: PrismaService, readonly prisma: PrismaService,
private readonly authService: AuthService, private readonly authService: AuthService,
@Inject(NextAuthOptionsProvide) @Inject(NextAuthOptionsProvide)
private readonly nextAuthOptions: NextAuthOptions private readonly nextAuthOptions: NextAuthOptions,
private readonly metrics: Metrics
) { ) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.callbackSession = nextAuthOptions.callbacks!.session; this.callbackSession = nextAuthOptions.callbacks!.session;
@@ -52,6 +54,7 @@ export class NextAuthController {
@Query() query: Record<string, any>, @Query() query: Record<string, any>,
@Next() next: NextFunction @Next() next: NextFunction
) { ) {
this.metrics.authCounter(1, {});
const [action, providerId] = req.url // start with request url const [action, providerId] = req.url // start with request url
.slice(BASE_URL.length) // make relative to baseUrl .slice(BASE_URL.length) // make relative to baseUrl
.replace(/\?.*/, '') // remove query part, use only path part .replace(/\?.*/, '') // remove query part, use only path part
@@ -83,6 +86,7 @@ export class NextAuthController {
const options = this.nextAuthOptions; const options = this.nextAuthOptions;
if (req.method === 'POST' && action === 'session') { if (req.method === 'POST' && action === 'session') {
if (typeof req.body !== 'object' || typeof req.body.data !== 'object') { if (typeof req.body !== 'object' || typeof req.body.data !== 'object') {
this.metrics.authFailCounter(1, { reason: 'invalid_session_data' });
throw new BadRequestException(`Invalid new session data`); throw new BadRequestException(`Invalid new session data`);
} }
const user = await this.updateSession(req, req.body.data); const user = await this.updateSession(req, req.body.data);
@@ -130,6 +134,9 @@ export class NextAuthController {
if (!req.headers?.referer) { if (!req.headers?.referer) {
res.redirect('https://community.affine.pro/c/insider-general/'); res.redirect('https://community.affine.pro/c/insider-general/');
} else { } else {
this.metrics.authFailCounter(1, {
reason: 'no_early_access_permission',
});
res.status(403); res.status(403);
res.json({ res.json({
url: 'https://community.affine.pro/c/insider-general/', url: 'https://community.affine.pro/c/insider-general/',

View File

@@ -143,8 +143,8 @@ describe('Trace Reporter', () => {
const traceSpan = TraceReporter.createTraceSpan( const traceSpan = TraceReporter.createTraceSpan(
traceId, traceId,
spanId, spanId,
requestId, startTime,
startTime { requestId }
); );
expect(traceSpan.startTime).toBe(startTime); expect(traceSpan.startTime).toBe(startTime);
expect( expect(
@@ -152,7 +152,7 @@ describe('Trace Reporter', () => {
`projects/{GCP_PROJECT_ID}/traces/${traceId}/spans/${spanId}` `projects/{GCP_PROJECT_ID}/traces/${traceId}/spans/${spanId}`
).toBe(true); ).toBe(true);
expect(traceSpan.spanId).toBe(spanId); expect(traceSpan.spanId).toBe(spanId);
expect(traceSpan.attributes.attributeMap.requestId.stringValue.value).toBe( expect(traceSpan.attributes.attributeMap.requestId?.stringValue.value).toBe(
requestId requestId
); );
}); });

View File

@@ -181,13 +181,14 @@ export const gqlFetcherFactory = (endpoint: string) => {
if (!isFormData) { if (!isFormData) {
headers['content-type'] = 'application/json'; headers['content-type'] = 'application/json';
} }
const ret = fetchWithReport( const ret = fetchWithTraceReport(
endpoint, endpoint,
merge(options.context, { merge(options.context, {
method: 'POST', method: 'POST',
headers, headers,
body: isFormData ? body : JSON.stringify(body), body: isFormData ? body : JSON.stringify(body),
}) }),
{ event: 'GraphQLRequest' }
).then(async res => { ).then(async res => {
if (res.headers.get('content-type')?.startsWith('application/json')) { if (res.headers.get('content-type')?.startsWith('application/json')) {
const result = (await res.json()) as ExecutionResult; const result = (await res.json()) as ExecutionResult;
@@ -214,9 +215,10 @@ export const gqlFetcherFactory = (endpoint: string) => {
return gqlFetch; return gqlFetch;
}; };
export const fetchWithReport = ( export const fetchWithTraceReport = (
input: RequestInfo | URL, input: RequestInfo | URL,
init?: RequestInit init?: RequestInit,
traceOptions?: { event: string }
): Promise<Response> => { ): Promise<Response> => {
const startTime = new Date().toISOString(); const startTime = new Date().toISOString();
const spanId = generateRandUTF16Chars(SPAN_ID_BYTES); const spanId = generateRandUTF16Chars(SPAN_ID_BYTES);
@@ -225,6 +227,7 @@ export const fetchWithReport = (
init = init || {}; init = init || {};
init.headers = init.headers || new Headers(); init.headers = init.headers || new Headers();
const requestId = nanoid(); const requestId = nanoid();
const event = traceOptions?.event;
if (init.headers instanceof Headers) { if (init.headers instanceof Headers) {
init.headers.append('x-request-id', requestId); init.headers.append('x-request-id', requestId);
init.headers.append('traceparent', traceparent); init.headers.append('traceparent', traceparent);
@@ -241,12 +244,18 @@ export const fetchWithReport = (
return fetch(input, init) return fetch(input, init)
.then(response => { .then(response => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
traceReporter!.cacheTrace(traceId, spanId, requestId, startTime); traceReporter!.cacheTrace(traceId, spanId, startTime, {
requestId,
...(event ? { event } : {}),
});
return response; return response;
}) })
.catch(err => { .catch(err => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
traceReporter!.uploadTrace(traceId, spanId, requestId, startTime); traceReporter!.uploadTrace(traceId, spanId, startTime, {
requestId,
...(event ? { event } : {}),
});
return Promise.reject(err); return Promise.reject(err);
}); });
}; };

View File

@@ -1,4 +1,5 @@
export * from './fetcher'; export * from './fetcher';
export * from './graphql'; export * from './graphql';
export * from './schema'; export * from './schema';
export * from './utils';
import '@affine/env/global'; import '@affine/env/global';

View File

@@ -16,12 +16,18 @@ type TraceSpan = {
endTime: string; endTime: string;
attributes: { attributes: {
attributeMap: { attributeMap: {
requestId: { requestId?: {
stringValue: { stringValue: {
value: string; value: string;
truncatedByteCount: number; truncatedByteCount: number;
}; };
}; };
event?: {
stringValue: {
value: string;
truncatedByteCount: 0;
};
};
}; };
droppedAttributesCount: number; droppedAttributesCount: number;
}; };
@@ -65,14 +71,17 @@ export class TraceReporter {
public cacheTrace( public cacheTrace(
traceId: string, traceId: string,
spanId: string, spanId: string,
requestId: string, startTime: string,
startTime: string attributes: {
requestId?: string;
event?: string;
}
) { ) {
const span = TraceReporter.createTraceSpan( const span = TraceReporter.createTraceSpan(
traceId, traceId,
spanId, spanId,
requestId, startTime,
startTime attributes
); );
this.spansCache.push(span); this.spansCache.push(span);
if (this.spansCache.length <= 1) { if (this.spansCache.length <= 1) {
@@ -83,14 +92,17 @@ export class TraceReporter {
public uploadTrace( public uploadTrace(
traceId: string, traceId: string,
spanId: string, spanId: string,
requestId: string, startTime: string,
startTime: string attributes: {
requestId?: string;
event?: string;
}
) { ) {
const span = TraceReporter.createTraceSpan( const span = TraceReporter.createTraceSpan(
traceId, traceId,
spanId, spanId,
requestId, startTime,
startTime attributes
); );
TraceReporter.reportToTraceEndpoint(JSON.stringify({ spans: [span] })); TraceReporter.reportToTraceEndpoint(JSON.stringify({ spans: [span] }));
} }
@@ -114,26 +126,46 @@ export class TraceReporter {
public static createTraceSpan( public static createTraceSpan(
traceId: string, traceId: string,
spanId: string, spanId: string,
requestId: string, startTime: string,
startTime: string attributes: {
requestId?: string;
event?: string;
}
): TraceSpan { ): TraceSpan {
const requestId = attributes.requestId;
const event = attributes.event;
return { return {
name: `projects/{GCP_PROJECT_ID}/traces/${traceId}/spans/${spanId}`, name: `projects/{GCP_PROJECT_ID}/traces/${traceId}/spans/${spanId}`,
spanId, spanId,
displayName: { displayName: {
value: 'fetch', value: 'AFFiNE_REQUEST',
truncatedByteCount: 0, truncatedByteCount: 0,
}, },
startTime, startTime,
endTime: new Date().toISOString(), endTime: new Date().toISOString(),
attributes: { attributes: {
attributeMap: { attributeMap: {
requestId: { ...(!requestId
stringValue: { ? {}
value: requestId, : {
truncatedByteCount: 0, requestId: {
}, stringValue: {
}, value: requestId,
truncatedByteCount: 0,
},
},
}),
...(!event
? {}
: {
event: {
stringValue: {
value: event,
truncatedByteCount: 0,
},
},
}),
}, },
droppedAttributesCount: 0, droppedAttributesCount: 0,
}, },

View File

@@ -1,6 +1,6 @@
import { import {
deleteBlobMutation, deleteBlobMutation,
fetchWithReport, fetchWithTraceReport,
listBlobsQuery, listBlobsQuery,
setBlobMutation, setBlobMutation,
} from '@affine/graphql'; } from '@affine/graphql';
@@ -12,7 +12,7 @@ export const createCloudBlobStorage = (workspaceId: string): BlobStorage => {
return { return {
crud: { crud: {
get: async key => { get: async key => {
return fetchWithReport( return fetchWithTraceReport(
runtimeConfig.serverUrlPrefix + runtimeConfig.serverUrlPrefix +
`/api/workspaces/${workspaceId}/blobs/${key}` `/api/workspaces/${workspaceId}/blobs/${key}`
).then(res => res.blob()); ).then(res => res.blob());

View File

@@ -1,5 +1,5 @@
import { DebugLogger } from '@affine/debug'; import { DebugLogger } from '@affine/debug';
import { fetchWithReport } from '@affine/graphql'; import { fetchWithTraceReport } from '@affine/graphql';
import type { ActiveDocProvider, DocProviderCreator } from '@blocksuite/store'; import type { ActiveDocProvider, DocProviderCreator } from '@blocksuite/store';
import { Workspace } from '@blocksuite/store'; import { Workspace } from '@blocksuite/store';
import type { Doc } from 'yjs'; import type { Doc } from 'yjs';
@@ -17,7 +17,7 @@ export async function downloadBinaryFromCloud(
if (hashMap.has(`${rootGuid}/${pageGuid}`)) { if (hashMap.has(`${rootGuid}/${pageGuid}`)) {
return true; return true;
} }
const response = await fetchWithReport( const response = await fetchWithTraceReport(
runtimeConfig.serverUrlPrefix + runtimeConfig.serverUrlPrefix +
`/api/workspaces/${rootGuid}/docs/${pageGuid}` `/api/workspaces/${rootGuid}/docs/${pageGuid}`
); );