chore: Move tracing decorators into the codebase (#4623)

* Vendorize tracing, finally fix service name issues

* Upgrade datadaog-metrics, rename decorators -> tracing

* lint
This commit is contained in:
Tom Moor
2022-12-31 12:54:51 +00:00
committed by GitHub
parent 1e036ebd0e
commit c6fb764631
26 changed files with 501 additions and 190 deletions

View File

@@ -0,0 +1,39 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { Tracer } from "dd-trace";
// eslint-disable-next-line @typescript-eslint/no-empty-function
const emptyFn = function () {};
const callableHandlers = {
get<T, P extends keyof T>(_target: T, _prop: P, _receiver: any): T[P] {
const newMock = new Proxy(emptyFn, callableHandlers);
return (newMock as any) as T[P];
},
apply<T extends (...args: any) => any, A extends Parameters<T>>(
_target: T,
_thisArg: any,
_args: A
): ReturnType<T> {
const newMock = new Proxy(emptyFn, callableHandlers);
return (newMock as any) as ReturnType<T>;
},
};
const callableMock = new Proxy(emptyFn, callableHandlers);
type MockTracer = Tracer & { isMock?: boolean };
export const mockTracer = new Proxy({} as MockTracer, {
get<K extends keyof MockTracer>(_target: Tracer, key: K) {
if (key === "isMock") {
return true;
}
if (key === "wrap") {
return (_: any, f: any) => f;
}
return callableMock;
},
});

View File

@@ -1,11 +1,11 @@
import { onAuthenticatePayload, Extension } from "@hocuspocus/server";
import { APM } from "@server/logging/tracing";
import { trace } from "@server/logging/tracing";
import Document from "@server/models/Document";
import { can } from "@server/policies";
import { getUserForJWT } from "@server/utils/jwt";
import { AuthenticationError } from "../errors";
@APM.trace()
@trace()
export default class AuthenticationExtension implements Extension {
async onAuthenticate({
connection,

View File

@@ -7,12 +7,12 @@ import {
import * as Y from "yjs";
import { sequelize } from "@server/database/sequelize";
import Logger from "@server/logging/Logger";
import { APM } from "@server/logging/tracing";
import { trace } from "@server/logging/tracing";
import Document from "@server/models/Document";
import documentCollaborativeUpdater from "../commands/documentCollaborativeUpdater";
import markdownToYDoc from "./utils/markdownToYDoc";
@APM.trace()
@trace()
export default class PersistenceExtension implements Extension {
/**
* Map of documentId -> userIds that have modified the document since it

View File

@@ -5,7 +5,7 @@ import {
InvalidAuthenticationError,
AuthenticationProviderDisabledError,
} from "@server/errors";
import { APM } from "@server/logging/tracing";
import { traceFunction } from "@server/logging/tracing";
import { AuthenticationProvider, Collection, Team, User } from "@server/models";
import teamProvisioner from "./teamProvisioner";
import userProvisioner from "./userProvisioner";
@@ -184,6 +184,6 @@ async function accountProvisioner({
}
}
export default APM.traceFunction({
export default traceFunction({
spanName: "accountProvisioner",
})(accountProvisioner);

View File

@@ -4,7 +4,7 @@ import {
FileOperationType,
FileOperationState,
} from "@shared/types";
import { APM } from "@server/logging/tracing";
import { traceFunction } from "@server/logging/tracing";
import { Collection, Event, Team, User, FileOperation } from "@server/models";
import { getAWSKeyForFileOp } from "@server/utils/s3";
@@ -71,6 +71,6 @@ async function collectionExporter({
return fileOperation;
}
export default APM.traceFunction({
export default traceFunction({
spanName: "collectionExporter",
})(collectionExporter);

View File

@@ -7,7 +7,7 @@ import { Transaction } from "sequelize";
import utf8 from "utf8";
import parseTitle from "@shared/utils/parseTitle";
import { DocumentValidation } from "@shared/validations";
import { APM } from "@server/logging/tracing";
import { traceFunction } from "@server/logging/tracing";
import { User } from "@server/models";
import dataURItoBuffer from "@server/utils/dataURItoBuffer";
import parseImages from "@server/utils/parseImages";
@@ -229,6 +229,6 @@ async function documentImporter({
};
}
export default APM.traceFunction({
export default traceFunction({
spanName: "documentImporter",
})(documentImporter);

View File

@@ -1,7 +1,7 @@
import invariant from "invariant";
import { Transaction } from "sequelize";
import { ValidationError } from "@server/errors";
import { APM } from "@server/logging/tracing";
import { traceFunction } from "@server/logging/tracing";
import { User, Document, Collection, Pin, Event } from "@server/models";
import pinDestroyer from "./pinDestroyer";
@@ -203,6 +203,6 @@ async function documentMover({
return result;
}
export default APM.traceFunction({
export default traceFunction({
spanName: "documentMover",
})(documentMover);

View File

@@ -1,7 +1,7 @@
import { Transaction } from "sequelize";
import slugify from "slugify";
import { RESERVED_SUBDOMAINS } from "@shared/utils/domains";
import { APM } from "@server/logging/tracing";
import { traceFunction } from "@server/logging/tracing";
import { Team, Event } from "@server/models";
import { generateAvatarUrl } from "@server/utils/avatars";
@@ -103,6 +103,6 @@ async function findAvailableSubdomain(team: Team, requestedSubdomain: string) {
return subdomain;
}
export default APM.traceFunction({
export default traceFunction({
spanName: "teamCreator",
})(teamCreator);

View File

@@ -1,7 +1,7 @@
import { Transaction } from "sequelize";
import { sequelize } from "@server/database/sequelize";
import Logger from "@server/logging/Logger";
import { APM } from "@server/logging/tracing";
import { traceFunction } from "@server/logging/tracing";
import {
ApiKey,
Attachment,
@@ -198,6 +198,6 @@ async function teamPermanentDeleter(team: Team) {
}
}
export default APM.traceFunction({
export default traceFunction({
spanName: "teamPermanentDeleter",
})(teamPermanentDeleter);

View File

@@ -6,7 +6,7 @@ import {
InvalidAuthenticationError,
MaximumTeamsError,
} from "@server/errors";
import { APM } from "@server/logging/tracing";
import { traceFunction } from "@server/logging/tracing";
import { Team, AuthenticationProvider } from "@server/models";
type TeamProvisionerResult = {
@@ -125,6 +125,6 @@ async function teamProvisioner({
};
}
export default APM.traceFunction({
export default traceFunction({
spanName: "teamProvisioner",
})(teamProvisioner);

View File

@@ -2,7 +2,7 @@ import nodemailer, { Transporter } from "nodemailer";
import Oy from "oy-vey";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import { APM } from "@server/logging/tracing";
import { trace } from "@server/logging/tracing";
import isCloudHosted from "@server/utils/isCloudHosted";
import { baseStyles } from "./templates/components/EmailLayout";
@@ -22,7 +22,9 @@ type SendMailOptions = {
/**
* Mailer class to send emails.
*/
@APM.trace()
@trace({
serviceName: "mailer",
})
export class Mailer {
transporter: Transporter | undefined;

View File

@@ -343,6 +343,11 @@ export class Environment {
*/
public DD_API_KEY = process.env.DD_API_KEY;
/**
* The name of the service to use in DataDog.
*/
public DD_SERVICE = process.env.DD_SERVICE ?? "outline";
/**
* Google OAuth2 client credentials. To enable authentication with Google.
*/

View File

@@ -1,7 +1,7 @@
/* eslint-disable import/order */
import env from "./env";
import "./logging/tracing"; // must come before importing any instrumented module
import "./logging/tracer"; // must come before importing any instrumented module
import http from "http";
import https from "https";

View File

@@ -5,7 +5,7 @@ import winston from "winston";
import env from "@server/env";
import Metrics from "@server/logging/Metrics";
import Sentry from "@server/logging/sentry";
import * as Tracing from "./tracing";
import * as Tracing from "./tracer";
const isProduction = env.ENVIRONMENT === "production";

View File

@@ -29,13 +29,8 @@ class Metrics {
return;
}
const instanceId = process.env.INSTANCE_ID || process.env.HEROKU_DYNO_ID;
if (!instanceId) {
throw new Error(
"INSTANCE_ID or HEROKU_DYNO_ID must be set when using DataDog"
);
}
const instanceId =
process.env.INSTANCE_ID || process.env.HEROKU_DYNO_ID || process.pid;
return ddMetrics.gauge(key, value, [...tags, `instance:${instanceId}`]);
}

92
server/logging/tracer.ts Normal file
View File

@@ -0,0 +1,92 @@
import tracer, { Span } from "dd-trace";
import env from "@server/env";
type PrivateDatadogContext = {
req: Record<string, any> & {
_datadog?: {
span?: Span;
};
};
};
// If the DataDog agent is installed and the DD_API_KEY environment variable is
// in the environment then we can safely attempt to start the DD tracer
if (env.DD_API_KEY) {
tracer.init({
version: env.VERSION,
service: env.DD_SERVICE,
env: env.ENVIRONMENT,
});
}
const getCurrentSpan = (): Span | null => tracer.scope().active();
/**
* Add tags to a span to have more context about how and why it was running.
* If added to the root span, tags are searchable and filterable.
*
* @param tags An object with the tags to add to the span
* @param span An optional span object to add the tags to. If none provided,the current span will be used.
*/
export function addTags(tags: Record<string, any>, span?: Span | null): void {
if (tracer) {
const currentSpan = span || getCurrentSpan();
if (!currentSpan) {
return;
}
currentSpan.addTags(tags);
}
}
/**
* The root span is an undocumented internal property that DataDog adds to `context.req`.
* The root span is required in order to add searchable tags.
* Unfortunately, there is no API to access the root span directly.
* See: node_modules/dd-trace/src/plugins/util/web.js
*
* @param context A Koa context object
*/
export function getRootSpanFromRequestContext(
context: PrivateDatadogContext
): Span | null {
// eslint-disable-next-line no-undef
return context?.req?._datadog?.span ?? null;
}
/**
* Change the resource of the active APM span. This method wraps addTags to allow
* safe use in environments where APM is disabled.
*
* @param name The name of the resource
*/
export function setResource(name: string) {
if (tracer) {
addTags({
"resource.name": `${name}`,
});
}
}
/**
* Mark the current active span as an error. This method wraps addTags to allow
* safe use in environments where APM is disabled.
*
* @param error The error to add to the current span
*/
export function setError(error: Error, span?: Span) {
if (tracer) {
addTags(
{
errorMessage: error.message,
"error.type": error.name,
"error.msg": error.message,
"error.stack": error.stack,
},
span
);
}
}
export default tracer;

View File

@@ -1,46 +1,212 @@
import { init, tracer, addTags, markAsError } from "@theo.gravity/datadog-apm";
// MIT License
// Copyright (c) 2020 GameChanger Media
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { SpanOptions } from "dd-trace";
import DDTags from "dd-trace/ext/tags";
import env from "@server/env";
import tracer, { setError } from "./tracer";
export * as APM from "@theo.gravity/datadog-apm";
type DDTag = typeof DDTags[keyof typeof DDTags];
// If the DataDog agent is installed and the DD_API_KEY environment variable is
// in the environment then we can safely attempt to start the DD tracer
if (env.DD_API_KEY) {
init(
{
version: env.VERSION,
service: process.env.DD_SERVICE || "outline",
},
{
useMock: env.ENVIRONMENT === "test",
type Tags = {
[tag in DDTag]?: any;
} & {
[key: string]: any;
};
interface Constructor {
new (...args: any[]): any;
}
interface TraceConfig {
className?: string;
methodName?: string;
serviceName?: string;
spanName?: string;
resourceName?: string;
isRoot?: boolean;
/** Cause the span to show up in trace search and analytics */
makeSearchable?: boolean;
tags?: Tags;
}
/**
* This decorator will cause an individual function to be traced by the APM.
*
* @param config Optional configuration for the span that will be created for this trace.
*/
export const traceFunction = (config: TraceConfig) => <
F extends (...args: any[]) => any,
P extends Parameters<F>,
R extends ReturnType<F>
>(
target: F
): F =>
env.ENVIRONMENT === "test"
? target
: (function wrapperFn(this: any, ...args: P): R {
const {
className,
methodName = target.name,
spanName = "DEFAULT_SPAN_NAME",
makeSearchable: useAnalytics,
tags,
} = config;
const childOf = config.isRoot
? undefined
: tracer.scope().active() || undefined;
const resourceName = config.resourceName
? config.resourceName
: className
? `${className}.${methodName}`
: methodName;
const spanOptions: SpanOptions = {
childOf,
tags: {
[DDTags.RESOURCE_NAME]: resourceName,
...tags,
},
};
const span = tracer.startSpan(spanName, spanOptions);
if (!span) {
return target.call(this, ...args);
}
if (config.serviceName) {
span.setTag(
DDTags.SERVICE_NAME,
`${env.DD_SERVICE}-${config.serviceName}`
);
}
if (useAnalytics) {
span.setTag(DDTags.ANALYTICS, true);
}
// The callback fn needs to be wrapped in an arrow fn as the activate fn clobbers `this`
return tracer.scope().activate(span, () => {
const output = target.call(this, ...args);
if (output && typeof output.then === "function") {
output
.catch((error: Error) => {
setError(error, span);
})
.finally(() => {
span.finish();
});
} else {
span.finish();
}
return output;
});
} as F);
const traceMethod = (config?: TraceConfig) =>
function <R, A extends any[], F extends (...args: A) => R>(
target: any,
_propertyKey: string,
descriptor: PropertyDescriptor
): TypedPropertyDescriptor<F> {
const wrappedFn = descriptor.value;
if (wrappedFn) {
const className = target.name || target.constructor.name; // target.name is needed if the target is the constructor itself
const methodName = wrappedFn.name;
descriptor.value = traceFunction({ ...config, className, methodName })(
wrappedFn
);
}
);
}
/**
* Change the resource of the active APM span. This method wraps addTags to allow
* safe use in environments where APM is disabled.
*
* @param name The name of the resource
*/
export function setResource(name: string) {
if (tracer) {
addTags({
"resource.name": `${name}`,
return descriptor;
};
const traceClass = (config?: TraceConfig) =>
function <T extends Constructor>(constructor: T): void {
const protoKeys = Reflect.ownKeys(constructor.prototype);
protoKeys.forEach((key) => {
if (key === "constructor") {
return;
}
const descriptor = Object.getOwnPropertyDescriptor(
constructor.prototype,
key
);
// eslint-disable-next-line no-undef
if (typeof key === "string" && typeof descriptor?.value === "function") {
Object.defineProperty(
constructor.prototype,
key,
traceMethod(config)(constructor, key, descriptor)
);
}
});
}
}
const staticKeys = Reflect.ownKeys(constructor);
staticKeys.forEach((key) => {
const descriptor = Object.getOwnPropertyDescriptor(constructor, key);
// eslint-disable-next-line no-undef
if (typeof key === "string" && typeof descriptor?.value === "function") {
Object.defineProperty(
constructor,
key,
traceMethod(config)(constructor, key, descriptor)
);
}
});
};
/**
* Mark the current active span as an error. This method wraps addTags to allow
* safe use in environments where APM is disabled.
* This decorator will cause the methods of a class, or an individual method, to be traced by the APM.
*
* @param error The error to add
* @param config Optional configuration for the span that will be created for this trace.
*/
export function setError(error: Error) {
if (tracer) {
markAsError(error);
// Going to rely on inferrence do its thing for this function
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export function trace(config?: TraceConfig) {
function traceDecorator(target: Constructor): void;
function traceDecorator<T>(
target: Record<string, any>,
propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<T>
): void;
function traceDecorator(
a: Constructor | Record<string, any>,
b?: any,
c?: any
): void {
if (typeof a === "function") {
// Need to cast as there is no safe runtime way to check if a function is a constructor
traceClass(config)(a as Constructor);
} else {
traceMethod(config)(a, b, c);
}
}
}
export default tracer;
return traceDecorator;
}

View File

@@ -1,6 +1,9 @@
import { Next } from "koa";
import Logger from "@server/logging/Logger";
import tracer, { APM } from "@server/logging/tracing";
import tracer, {
addTags,
getRootSpanFromRequestContext,
} from "@server/logging/tracer";
import { User, Team, ApiKey } from "@server/models";
import { getUserForJWT } from "@server/utils/jwt";
import {
@@ -130,13 +133,13 @@ export default function auth(options: AuthenticationOptions = {}) {
ctx.state.user = user;
if (tracer) {
APM.addTags(
addTags(
{
"request.userId": user.id,
"request.teamId": user.teamId,
"request.authType": ctx.state.authType,
},
APM.getRootSpanFromRequestContext(ctx)
getRootSpanFromRequestContext(ctx)
);
}
}

View File

@@ -23,7 +23,7 @@ import { isRTL } from "@shared/utils/rtl";
import unescape from "@shared/utils/unescape";
import { parser, schema } from "@server/editor";
import Logger from "@server/logging/Logger";
import { APM } from "@server/logging/tracing";
import { trace } from "@server/logging/tracing";
import type Document from "@server/models/Document";
import type Revision from "@server/models/Revision";
import User from "@server/models/User";
@@ -43,7 +43,7 @@ type HTMLOptions = {
signedUrls?: boolean;
};
@APM.trace()
@trace()
export default class DocumentHelper {
/**
* Returns the document as a Prosemirror Node. This method uses the

View File

@@ -1,4 +1,4 @@
import { APM } from "@server/logging/tracing";
import { traceFunction } from "@server/logging/tracing";
import { Document } from "@server/models";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
import presentUser from "./user";
@@ -63,6 +63,6 @@ async function present(
return data;
}
export default APM.traceFunction({
export default traceFunction({
spanName: "presentDocument",
})(present);

View File

@@ -1,4 +1,4 @@
import { APM } from "@server/logging/tracing";
import { traceFunction } from "@server/logging/tracing";
import { User } from "@server/models";
type Policy = {
@@ -16,6 +16,6 @@ function present(user: User, objects: Record<string, any>[]): Policy[] {
}));
}
export default APM.traceFunction({
export default traceFunction({
spanName: "presentPolicy",
})(present);

View File

@@ -1,4 +1,4 @@
import { APM } from "@server/logging/tracing";
import { traceFunction } from "@server/logging/tracing";
import { Document, Collection, Team } from "@server/models";
type Action = {
@@ -33,6 +33,6 @@ function present(
};
}
export default APM.traceFunction({
export default traceFunction({
spanName: "presentSlackAttachment",
})(present);

View File

@@ -6,8 +6,8 @@ import IO from "socket.io";
import { createAdapter } from "socket.io-redis";
import Logger from "@server/logging/Logger";
import Metrics from "@server/logging/Metrics";
import * as Tracing from "@server/logging/tracing";
import { APM } from "@server/logging/tracing";
import * as Tracing from "@server/logging/tracer";
import { traceFunction } from "@server/logging/tracing";
import { Document, Collection, View, User } from "@server/models";
import { can } from "@server/policies";
import { getUserForJWT } from "@server/utils/jwt";
@@ -131,7 +131,7 @@ export default function init(
// Handle events from event queue that should be sent to the clients down ws
const websockets = new WebsocketsProcessor();
websocketQueue.process(
APM.traceFunction({
traceFunction({
serviceName: "websockets",
spanName: "process",
isRoot: true,

View File

@@ -1,6 +1,6 @@
import Logger from "@server/logging/Logger";
import * as Tracing from "@server/logging/tracing";
import { APM } from "@server/logging/tracing";
import { setResource } from "@server/logging/tracer";
import { traceFunction } from "@server/logging/tracing";
import {
globalEventQueue,
processorEventQueue,
@@ -13,7 +13,7 @@ import tasks from "../queues/tasks";
export default function init() {
// This queue processes the global event bus
globalEventQueue.process(
APM.traceFunction({
traceFunction({
serviceName: "worker",
spanName: "process",
isRoot: true,
@@ -21,7 +21,7 @@ export default function init() {
const event = job.data;
let err;
Tracing.setResource(`Event.${event.name}`);
setResource(`Event.${event.name}`);
Logger.info("worker", `Processing ${event.name}`, {
name: event.name,
@@ -71,7 +71,7 @@ export default function init() {
// Jobs for individual processors are processed here. Only applicable events
// as unapplicable events were filtered in the global event queue above.
processorEventQueue.process(
APM.traceFunction({
traceFunction({
serviceName: "worker",
spanName: "process",
isRoot: true,
@@ -79,7 +79,7 @@ export default function init() {
const { event, name } = job.data;
const ProcessorClass = processors[name];
Tracing.setResource(`Processor.${name}`);
setResource(`Processor.${name}`);
if (!ProcessorClass) {
throw new Error(
@@ -107,7 +107,7 @@ export default function init() {
// Jobs for async tasks are processed here.
taskQueue.process(
APM.traceFunction({
traceFunction({
serviceName: "worker",
spanName: "process",
isRoot: true,
@@ -115,7 +115,7 @@ export default function init() {
const { name, props } = job.data;
const TaskClass = tasks[name];
Tracing.setResource(`Task.${name}`);
setResource(`Task.${name}`);
if (!TaskClass) {
throw new Error(