Skip to content
Open
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
23 changes: 22 additions & 1 deletion NOTICES
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,28 @@ is reproduced below.

-------------------------------------------------------------------------------

2. lib/integrations/prisma.ts
2. lib/integrations/sequelize.ts

This file is derived from @opentelemetry/instrumentation-sequelize
(https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-sequelize)

Copyright The OpenTelemetry Authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

The approach of patching `Sequelize.prototype.query` to intercept all
database queries, extracting the operation from `options.type` (falling
back to the leading SQL keyword), and capturing `db.statement` and
`db.operation` span attributes is derived from the OpenTelemetry
Sequelize instrumentation.

-------------------------------------------------------------------------------

3. lib/integrations/prisma.ts

This file is derived from @prisma/instrumentation
(https://github.com/prisma/prisma/tree/main/packages/instrumentation)
Expand Down
3 changes: 3 additions & 0 deletions lib/integrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import fetchIntegration from "./fetch";
import redisIntegration from "./redis";
import mongodbIntegration from "./mongodb";
import bullmqIntegration from "./bullmq";
import sequelizeIntegration from "./sequelize";
import { doNothingRequireIntegration, RequireIntegration } from "../types/integrations";

export const KNOWN_PACKAGES: string[] = [
Expand All @@ -31,6 +32,7 @@ export const KNOWN_PACKAGES: string[] = [
redisIntegration.getPackageName(),
mongodbIntegration.getPackageName(),
bullmqIntegration.getPackageName(),
sequelizeIntegration.getPackageName(),
];

export function getIntegrationForPackage(pkg: string): RequireIntegration {
Expand All @@ -50,6 +52,7 @@ export function getIntegrationForPackage(pkg: string): RequireIntegration {
case redisIntegration.getPackageName(): return redisIntegration;
case mongodbIntegration.getPackageName(): return mongodbIntegration;
case bullmqIntegration.getPackageName(): return bullmqIntegration;
case sequelizeIntegration.getPackageName(): return sequelizeIntegration;
default: return doNothingRequireIntegration;
}
}
78 changes: 78 additions & 0 deletions lib/integrations/sequelize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { RequireIntegration, getIntegrationSymbol } from "../types/integrations";
import { ScoutContextName, ScoutSpanOperation } from "../types";

function extractOperation(sql: string, opts?: any): string {
if (opts?.type) { return String(opts.type).toUpperCase(); }
const first = sql.trim().split(/\s+/)[0];
return first ? first.toUpperCase() : "UNKNOWN";
}

function extractTable(sql: string, opts?: any): string {
// Tier 1: model instance
const fromInstance = opts?.instance?.constructor?.tableName;
if (fromInstance) { return fromInstance; }
// Tier 2: tableNames array
if (Array.isArray(opts?.tableNames) && opts.tableNames.length > 0) {
return opts.tableNames.join(",");
}
// Tier 3: regex on SQL (FROM / JOIN / INTO / UPDATE)
const match = sql.match(/(?:from|join|into|update)\s+["`]?(\w+)["`]?/i);
return match ? match[1] : "";
}

export class SequelizeIntegration extends RequireIntegration {
protected readonly packageName: string = "sequelize";

protected shim(sequelizeExport: any): any {
const Sequelize = sequelizeExport.Sequelize ?? sequelizeExport;
if (!Sequelize?.prototype?.query) { return sequelizeExport; }

const originalQuery = Sequelize.prototype.query;
if (!originalQuery) { return sequelizeExport; }

Sequelize[getIntegrationSymbol()] = this;

const integration = this;

Sequelize.prototype.query = function(sql: any, options?: any) {
if (!integration.scout) {
return originalQuery.apply(this, [sql, options]);
}

const sqlText = typeof sql === "string" ? sql : (sql?.query ?? "");
const operation = extractOperation(sqlText, options);
const table = extractTable(sqlText, options);

return integration.scout.instrument(ScoutSpanOperation.SQLQuery, (done: any) => {
if (!integration.scout) {
return originalQuery.apply(this, [sql, options]).then((r: any) => { done(); return r; });
}

const span = integration.scout.getCurrentSpan();

return originalQuery.apply(this, [sql, options])
.then((result: any) => {
if (span) {
span.addContextSync(ScoutContextName.DBStatement, sqlText);
span.addContextSync(ScoutContextName.DBOperation, operation);
if (table) { span.addContextSync(ScoutContextName.DBModel, table); }
}
done();
return result;
})
.catch((err: any) => {
if (span) {
span.addContextSync(ScoutContextName.DBStatement, sqlText);
span.addContextSync(ScoutContextName.Error, "true");
}
done();
throw err;
});
});
};

return sequelizeExport;
}
}

export default new SequelizeIntegration();
2 changes: 2 additions & 0 deletions lib/types/enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ export enum ScoutContextName {
Queue = "queue",
TaskId = "task_id",
Priority = "priority",
DBOperation = "db.operation",
DBModel = "db.model",
}

export enum ScoutSpanOperation {
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,15 @@
"mustache-express": "^1.3.2",
"mysql": "^2.18.1",
"mysql2": "^3.11.5",
"pg": "^8.13.1",
"pg": "^8.0.0",
"pg-hstore": "^2.3.4",
"prisma": "^6.0.0",
"pug": "^3.0.3",
"randomstring": "^1.3.0",
"redis": "^5.0.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0",
"sequelize": "^6.37.5",
"sequelize": "^6.37.0",
"sha256-file": "^1.0.0",
"supertest": "^4.0.2",
"tape": "^4.12.1",
Expand Down
197 changes: 197 additions & 0 deletions test/integrations/sequelize.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { setupRequireIntegrations } from "../../lib";
setupRequireIntegrations(["sequelize"]);

import { Sequelize, DataTypes } from "sequelize";
import * as test from "tape";
import * as TestUtil from "../util";
import { getIntegrationSymbol } from "../../lib/types/integrations";
import { ScoutEvent, buildScoutConfiguration } from "../../lib/types";
import { Scout, ScoutEventRequestSentData } from "../../lib/scout";
import { MockAgent } from "../integration/mock-agent";

const PG_HOST = process.env.PGHOST || "127.0.0.1";
const PG_PORT = parseInt(process.env.PGPORT || "5432", 10);
const PG_USER = process.env.PGUSER || "postgres";
const PG_PASSWORD = process.env.PGPASSWORD || "postgres";
const PG_DB = process.env.PGDATABASE || "scout_sequelize_test";
const TIMEOUT_MS = 20000;

const sharedMock = new MockAgent();

function makeSequelize(): Sequelize {
return new Sequelize(PG_DB, PG_USER, PG_PASSWORD, {
host: PG_HOST,
port: PG_PORT,
dialect: "postgres",
logging: false,
});
}

function makeUserModel(seq: Sequelize) {
return seq.define("User", {
name: { type: DataTypes.STRING, allowNull: false },
email: { type: DataTypes.STRING, allowNull: false },
}, { tableName: "sequelize_test_users", timestamps: false });
}

test("setup: start shared mock agent", (t) => {
sharedMock.start().then(() => t.end()).catch(t.end);
});

test("sequelize Sequelize class has integration symbol", (t) => {
const { Sequelize: Seq } = require("sequelize");
t.ok((Seq as any)[getIntegrationSymbol()], "Sequelize class has integration symbol");
t.end();
});

test("SQL/Query span created for findAll", { timeout: TIMEOUT_MS }, (t) => {
const seq = makeSequelize();
const User = makeUserModel(seq);
const scout = new Scout(buildScoutConfiguration({
monitor: true,
coreAgentDownload: false,
coreAgentLaunch: false,
socketPath: sharedMock.socketPath(),
}));

const cleanup = (err?: any) => seq.close().then(() => TestUtil.shutdownScout(t, scout, err));

const listener = (data: ScoutEventRequestSentData) => {
const spans = data.request.getChildSpansSync();
const sqlSpan = spans.find((s) => s.operation === "SQL/Query");
if (!sqlSpan) { return; }

scout.removeListener(ScoutEvent.RequestSent, listener);

t.ok(sqlSpan, "SQL/Query span present");
const stmt = sqlSpan.getContextValue("db.statement");
t.ok(stmt && String(stmt).toUpperCase().includes("SELECT"), "db.statement contains SELECT");

cleanup().catch((e) => t.end(e));
};

scout.on(ScoutEvent.RequestSent, listener);

scout.setup()
.then(() => seq.sync({ force: true }))
.then(() => scout.transaction("Controller/GET /users", (finish) => {
return User.findAll().then(() => finish());
}))
.catch(cleanup);
});

test("SQL/Query span created for create", { timeout: TIMEOUT_MS }, (t) => {
const seq = makeSequelize();
const User = makeUserModel(seq);
const scout = new Scout(buildScoutConfiguration({
monitor: true,
coreAgentDownload: false,
coreAgentLaunch: false,
socketPath: sharedMock.socketPath(),
}));

const cleanup = (err?: any) => seq.close().then(() => TestUtil.shutdownScout(t, scout, err));

const listener = (data: ScoutEventRequestSentData) => {
const spans = data.request.getChildSpansSync();
const sqlSpan = spans.find((s) => {
const stmt = s.getContextValue("db.statement");
return s.operation === "SQL/Query" && stmt && String(stmt).toUpperCase().includes("INSERT");
});
if (!sqlSpan) { return; }

scout.removeListener(ScoutEvent.RequestSent, listener);

t.ok(sqlSpan, "SQL/Query INSERT span present");
const stmt = sqlSpan.getContextValue("db.statement");
t.ok(stmt && String(stmt).toUpperCase().includes("INSERT"), "db.statement contains INSERT");

cleanup().catch((e) => t.end(e));
};

scout.on(ScoutEvent.RequestSent, listener);

scout.setup()
.then(() => seq.sync({ force: true }))
.then(() => scout.transaction("Controller/POST /users", (finish) => {
return User.create({ name: "Alice", email: "alice@example.com" }).then(() => finish());
}))
.catch(cleanup);
});

test("SQL/Query span created for raw query", { timeout: TIMEOUT_MS }, (t) => {
const seq = makeSequelize();
const User = makeUserModel(seq);
const scout = new Scout(buildScoutConfiguration({
monitor: true,
coreAgentDownload: false,
coreAgentLaunch: false,
socketPath: sharedMock.socketPath(),
}));

const cleanup = (err?: any) => seq.close().then(() => TestUtil.shutdownScout(t, scout, err));

const listener = (data: ScoutEventRequestSentData) => {
const spans = data.request.getChildSpansSync();
const sqlSpan = spans.find((s) => {
const stmt = s.getContextValue("db.statement");
return s.operation === "SQL/Query" && stmt && String(stmt).includes("sequelize_test_users");
});
if (!sqlSpan) { return; }

scout.removeListener(ScoutEvent.RequestSent, listener);

t.ok(sqlSpan, "SQL/Query span present for raw query");
const stmt = sqlSpan.getContextValue("db.statement");
t.ok(stmt && String(stmt).includes("sequelize_test_users"), "db.statement contains raw SQL");

cleanup().catch((e) => t.end(e));
};

scout.on(ScoutEvent.RequestSent, listener);

scout.setup()
.then(() => seq.sync({ force: true }))
.then(() => scout.transaction("Controller/GET /raw", (finish) => {
return seq.query("SELECT * FROM sequelize_test_users").then(() => finish());
}))
.catch(cleanup);
});

test("error flag set when query fails", { timeout: TIMEOUT_MS }, (t) => {
const seq = makeSequelize();
const scout = new Scout(buildScoutConfiguration({
monitor: true,
coreAgentDownload: false,
coreAgentLaunch: false,
socketPath: sharedMock.socketPath(),
}));

const cleanup = (err?: any) => seq.close().then(() => TestUtil.shutdownScout(t, scout, err));

const listener = (data: ScoutEventRequestSentData) => {
const spans = data.request.getChildSpansSync();
const sqlSpan = spans.find((s) => s.operation === "SQL/Query");
if (!sqlSpan) { return; }

scout.removeListener(ScoutEvent.RequestSent, listener);

t.equal(sqlSpan.getContextValue("error"), "true", "error context is 'true' on failed query");

cleanup().catch((e) => t.end(e));
};

scout.on(ScoutEvent.RequestSent, listener);

scout.setup()
.then(() => scout.transaction("Controller/GET /bad", (finish) => {
return seq.query("SELECT * FROM table_that_does_not_exist_xyz")
.catch(() => undefined)
.then(() => finish());
}))
.catch(cleanup);
});

test("teardown: stop shared mock agent", (t) => {
sharedMock.stop().then(() => t.end()).catch(t.end);
});
Loading
Loading