Skip to content

Commit b2671a3

Browse files
authored
Merge pull request #3359 from SeedCompany/tracing
Optimize automatic DB query names with TraceLayer & ALS
2 parents fe6f2b3 + 742c319 commit b2671a3

14 files changed

+268
-98
lines changed

src/common/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export * from './secured-property';
3939
export * from './secured-date';
4040
export * from './secured-mapper';
4141
export * from './sensitivity.enum';
42+
export * from './trace-layer';
4243
export * from './util';
4344
export { Session, LoggedInSession, AnonSession } from './session';
4445
export * from './types';

src/common/trace-layer.ts

+149
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { cacheable, cached } from '@seedcompany/common';
2+
import { patchDecoratedMethod as patchMethod } from '@seedcompany/nest';
3+
import { AsyncLocalStorage } from 'async_hooks';
4+
import { AbstractClass } from 'type-fest';
5+
import { getParentTypes as getHierarchyList } from './parent-types';
6+
7+
export type TraceNames = Readonly<{
8+
cls: string;
9+
/** The parent class that declares the method when it is not the current class */
10+
ownCls?: string;
11+
method: string;
12+
}>;
13+
14+
/**
15+
* A performant way to identify certain methods in the call stack.
16+
*
17+
* This class helps to wrap class methods
18+
* and store their names in an AsyncLocalStorage.
19+
* This allows helper methods to identify who called them,
20+
* assuming they've been preconfigured.
21+
*
22+
* Traces are wrapped with a "layer"/"group" name.
23+
* This allows different groups of methods to be intertwined in the call
24+
* stack without conflicting with each other.
25+
* Multiple layers can be applied to the same method, independently.
26+
*
27+
* Best starting point is {@link applyToInstance}
28+
* ```ts
29+
* class Foo {
30+
* constructor() {
31+
* TraceLayer.as('logic').applyToInstance(this);
32+
* }
33+
* }
34+
* ```
35+
* This capture approach is best because it automatically
36+
* applies to subclasses and applies to inherited methods.
37+
*
38+
* Next best is {@link applyToClass}
39+
* ```ts
40+
* @TraceLayer.as('logic').applyToClass()
41+
* class Foo {}
42+
* ```
43+
* This applies to all owned & inherited methods.
44+
*
45+
* The current stack can be pulled anywhere with
46+
* ```ts
47+
* TraceLayer.as('logic').currentStack;
48+
* ```
49+
* This gives a list of call names ordered by most recent.
50+
* ```ts
51+
* const { cls, method } = currentStack?.[0] ?? {};
52+
* ```
53+
*/
54+
export class TraceLayer {
55+
private static readonly layers = new AsyncLocalStorage<{
56+
readonly [layer in string]?: readonly TraceNames[];
57+
}>();
58+
private static readonly instances = new Map<string, TraceLayer>();
59+
private static readonly getNameCacheForInstance = cacheable<
60+
object,
61+
Map<string, TraceNames>
62+
>(new WeakMap(), () => new Map());
63+
64+
private readonly seenClasses = new WeakSet<AbstractClass<unknown>>();
65+
66+
private constructor(readonly layer: string) {}
67+
68+
static as(layer: string) {
69+
return cached(TraceLayer.instances, layer, () => new TraceLayer(layer));
70+
}
71+
72+
/**
73+
* This gives a list of call names, if any, ordered by most recent.
74+
* The identity of these entries is maintained,
75+
* allowing them to be used as cache keys.
76+
*/
77+
get currentStack() {
78+
const layers = TraceLayer.layers.getStore();
79+
return layers?.[this.layer];
80+
}
81+
82+
/**
83+
* A shortcut to create a memoized function that takes the current trace
84+
* and converts it to another shape.
85+
*/
86+
makeGetter<T>(mapFnCached: (names: TraceNames) => T) {
87+
const cache = new WeakMap();
88+
return () => {
89+
const names = this.currentStack?.[0];
90+
return names ? cached(cache, names, mapFnCached) : undefined;
91+
};
92+
}
93+
94+
applyToClass(): ClassDecorator {
95+
return (target) => {
96+
this.applyToStaticClass(target as any);
97+
};
98+
}
99+
100+
applyToInstance(obj: InstanceType<AbstractClass<any>>) {
101+
this.applyToStaticClass(obj.constructor);
102+
}
103+
104+
applyToStaticClass(cls: AbstractClass<unknown>) {
105+
for (const aClass of getHierarchyList(cls)) {
106+
this.applyToOwnStaticClass(aClass);
107+
}
108+
}
109+
110+
applyToOwnStaticClass(cls: AbstractClass<unknown>) {
111+
if (this.seenClasses.has(cls)) {
112+
return;
113+
}
114+
this.seenClasses.add(cls);
115+
116+
const proto = cls.prototype;
117+
const descriptors = Object.getOwnPropertyDescriptors(proto);
118+
const methods = Object.entries(descriptors).flatMap(([key, descriptor]) => {
119+
return key !== 'constructor' && typeof descriptor.value === 'function'
120+
? [key]
121+
: [];
122+
});
123+
124+
const layer = this.layer;
125+
for (const name of methods) {
126+
patchMethod(proto as any, name, (original) => {
127+
return function (...args) {
128+
const nameCache = TraceLayer.getNameCacheForInstance(this);
129+
const names = cached(nameCache, name, (): TraceNames => {
130+
const cls = this.constructor.name;
131+
const ownCls = proto.constructor.name;
132+
return {
133+
cls: cls,
134+
...(cls !== ownCls && { ownCls }),
135+
method: name,
136+
};
137+
});
138+
139+
const prev = TraceLayer.layers.getStore();
140+
const next = {
141+
...prev,
142+
[layer]: [names, ...(prev?.[layer] ?? [])],
143+
};
144+
return TraceLayer.layers.run(next, original, ...args);
145+
};
146+
});
147+
}
148+
}
149+
}

src/components/admin/admin.gel.repository.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { Injectable } from '@nestjs/common';
22
import { ID, Role } from '~/common';
33
import { RootUserAlias } from '~/core/config/root-user.config';
4-
import { disableAccessPolicies, e, Gel } from '~/core/gel';
4+
import { DbTraceLayer, disableAccessPolicies, e, Gel } from '~/core/gel';
55
import { AuthenticationRepository } from '../authentication/authentication.repository';
66
import { SystemAgentRepository } from '../user/system-agent.repository';
77

88
@Injectable()
9+
@DbTraceLayer.applyToClass()
910
export class AdminGelRepository {
1011
private readonly db: Gel;
1112
constructor(

src/components/authentication/authentication.gel.repository.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,18 @@ import { Injectable } from '@nestjs/common';
22
import { IntegrityError } from 'gel';
33
import { ID, PublicOf, ServerException, Session } from '~/common';
44
import { RootUserAlias } from '~/core/config/root-user.config';
5-
import { disableAccessPolicies, e, Gel, withScope } from '~/core/gel';
5+
import {
6+
DbTraceLayer,
7+
disableAccessPolicies,
8+
e,
9+
Gel,
10+
withScope,
11+
} from '~/core/gel';
612
import type { AuthenticationRepository } from './authentication.repository';
713
import { LoginInput } from './dto';
814

915
@Injectable()
16+
@DbTraceLayer.applyToClass()
1017
export class AuthenticationGelRepository
1118
implements PublicOf<AuthenticationRepository>
1219
{

src/components/authentication/authentication.repository.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
22
import { node, relation } from 'cypher-query-builder';
33
import { DateTime } from 'luxon';
44
import { ID, ServerException, Session } from '~/common';
5-
import { DatabaseService, OnIndex } from '~/core/database';
5+
import { DatabaseService, DbTraceLayer, OnIndex } from '~/core/database';
66
import {
77
ACTIVE,
88
matchUserGloballyScopedRoles,
@@ -19,6 +19,7 @@ interface EmailToken {
1919
}
2020

2121
@Injectable()
22+
@DbTraceLayer.applyToClass()
2223
export class AuthenticationRepository {
2324
constructor(private readonly db: DatabaseService) {}
2425

src/components/pin/pin.repository.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { Injectable } from '@nestjs/common';
22
import { node, relation } from 'cypher-query-builder';
33
import { DateTime } from 'luxon';
44
import { ID, Session } from '~/common';
5-
import { DatabaseService } from '~/core/database';
5+
import { DatabaseService, DbTraceLayer } from '~/core/database';
66
import { requestingUser } from '~/core/database/query';
77

88
@Injectable()
9+
@DbTraceLayer.applyToClass()
910
export class PinRepository {
1011
constructor(private readonly db: DatabaseService) {}
1112

src/components/user/system-agent.repository.ts

+5
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { Injectable } from '@nestjs/common';
22
import { CachedByArg } from '@seedcompany/common';
33
import { Role } from '~/common';
4+
import { DbTraceLayer } from '~/core/database';
45
import { SystemAgent } from './dto';
56

67
@Injectable()
78
export abstract class SystemAgentRepository {
9+
constructor() {
10+
DbTraceLayer.applyToInstance(this);
11+
}
12+
813
@CachedByArg()
914
async getAnonymous() {
1015
return await this.upsertAgent('Anonymous');

src/core/database/common.repository.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
ServerException,
1515
} from '~/common';
1616
import { ResourceLike, ResourcesHost } from '../resources';
17-
import { DatabaseService } from './database.service';
17+
import { DatabaseService, DbTraceLayer } from './database.service';
1818
import { createUniqueConstraint } from './indexer';
1919
import { ACTIVE, deleteBaseNode, updateRelationList } from './query';
2020
import { BaseNode } from './results';
@@ -27,6 +27,10 @@ export class CommonRepository {
2727
@Inject() protected db: DatabaseService;
2828
@Inject() protected readonly resources: ResourcesHost;
2929

30+
constructor() {
31+
DbTraceLayer.applyToInstance(this);
32+
}
33+
3034
async getBaseNode(
3135
id: ID,
3236
label?: string | ResourceShape<any> | EnhancedResource<any>,

src/core/database/cypher.factory.ts

+17-15
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { jestSkipFileInExceptionSource } from '../exception';
1414
import { ILogger, LoggerToken, LogLevel } from '../logger';
1515
import { AFTER_MESSAGE } from '../logger/formatters';
1616
import { TracingService } from '../tracing';
17+
import { DbTraceLayer } from './database.service';
1718
import {
1819
createBetterError,
1920
isNeo4jError,
@@ -181,21 +182,14 @@ export const CypherFactory: FactoryProvider<Connection> = {
181182
conn.query = () => {
182183
const q = origCreateQuery();
183184

184-
let stack = new Error('').stack?.split('\n').slice(2);
185-
if (stack?.[0]?.startsWith(' at DatabaseService.query')) {
186-
stack = stack.slice(1);
187-
}
188-
if (!stack) {
189-
return q;
190-
}
191-
185+
const stack = new Error();
192186
(q as any).__stacktrace = stack;
193-
const frame = stack?.[0] ? /at (.+) \(/.exec(stack[0]) : undefined;
194-
(q as any).name = frame?.[1].replace('Repository', '');
187+
const queryName = getCurrentQueryName();
188+
(q as any).name = queryName;
195189

196190
const orig = q.run.bind(q);
197191
q.run = async () => {
198-
return await tracing.capture((q as any).name ?? 'Query', (sub) => {
192+
return await tracing.capture(queryName ?? 'Query', (sub) => {
199193
// Show this segment separately in service map
200194
sub.namespace = 'remote';
201195
// Help ID the segment as being for a database
@@ -212,13 +206,13 @@ export const CypherFactory: FactoryProvider<Connection> = {
212206
q.buildQueryObject = function () {
213207
const result = origBuild();
214208
Object.defineProperty(result.params, '__stacktrace', {
215-
value: stack?.join('\n'),
209+
value: stack,
216210
enumerable: false,
217211
configurable: true,
218212
writable: true,
219213
});
220214
Object.defineProperty(result.params, '__origin', {
221-
value: (q as any).name,
215+
value: queryName,
222216
enumerable: false,
223217
configurable: true,
224218
writable: true,
@@ -286,8 +280,12 @@ const wrapQueryRun = (
286280
// Stack doesn't matter for connection errors, as it's not caused by
287281
// the specific DB query.
288282
e.stack = e.stack.slice(0, stackStart).trim();
289-
} else if (typeof parameters?.__stacktrace === 'string' && e.stack) {
290-
e.stack = e.stack.slice(0, stackStart) + parameters.__stacktrace;
283+
} else if (parameters && parameters.__stacktrace instanceof Error) {
284+
let stack = parameters.__stacktrace.stack!.split('\n').slice(2);
285+
if (stack[0]?.startsWith(' at DatabaseService.query')) {
286+
stack = stack.slice(1);
287+
}
288+
e.stack = e.stack.slice(0, stackStart) + stack.join('\n');
291289
}
292290
}
293291
jestSkipFileInExceptionSource(e, fileURLToPath(import.meta.url));
@@ -319,3 +317,7 @@ const wrapQueryRun = (
319317
return result;
320318
};
321319
};
320+
321+
const getCurrentQueryName = DbTraceLayer.makeGetter(({ cls, method }) => {
322+
return `${cls.replace(/Repository$/, '')}.${method}`;
323+
});

src/core/database/database.service.ts

+3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
MaybeUnsecuredInstance,
1414
ResourceShape,
1515
ServerException,
16+
TraceLayer,
1617
UnwrapSecured,
1718
} from '~/common';
1819
import { AbortError, retry, RetryOptions } from '~/common/retry';
@@ -34,6 +35,8 @@ import {
3435
variable,
3536
} from './query';
3637

38+
export const DbTraceLayer = TraceLayer.as('db');
39+
3740
export interface ServerInfo {
3841
version: [major: number, minor: number, patch: number];
3942
/** Major.Minor float number */

src/core/database/migration/migration-runner.service.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import { node } from 'cypher-query-builder';
33
import { DateTime } from 'luxon';
44
import { ConfigService } from '../../config/config.service';
55
import { ILogger, Logger } from '../../logger';
6-
import { DatabaseService } from '../database.service';
6+
import { DatabaseService, DbTraceLayer } from '../database.service';
77
import {
88
DiscoveredMigration,
99
MigrationDiscovery,
1010
} from './migration-discovery.service';
1111

1212
@Injectable()
13+
@DbTraceLayer.applyToClass()
1314
export class MigrationRunner {
1415
constructor(
1516
private readonly db: DatabaseService,

src/core/gel/common.repository.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { EnhancedResource, ID, isIdLike, PublicOf } from '~/common';
33
import type { CommonRepository as Neo4jCommonRepository } from '~/core/database';
44
import { ResourceLike, ResourcesHost } from '~/core/resources/resources.host';
55
import type { BaseNode } from '../database/results';
6-
import { Gel } from './gel.service';
6+
import { DbTraceLayer, Gel } from './gel.service';
77
import { e } from './reexports';
88

99
/**
@@ -14,6 +14,10 @@ export class CommonRepository implements PublicOf<Neo4jCommonRepository> {
1414
@Inject() protected readonly db: Gel;
1515
@Inject() protected readonly resources: ResourcesHost;
1616

17+
constructor() {
18+
DbTraceLayer.applyToInstance(this);
19+
}
20+
1721
/**
1822
* Here for compatibility with the Neo4j version.
1923
* @deprecated this should be replaced with a different output shape,

0 commit comments

Comments
 (0)