@forge-ops/tracker 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +33 -4
  2. package/package.json +1 -1
  3. package/src/index.js +43 -2
package/README.md CHANGED
@@ -539,10 +539,39 @@ app.use(forgeOpsTrackerExpressMiddleware);
539
539
  app.listen(3000);
540
540
  ```
541
541
 
542
- **Known gaps:** no database span capture (this SDK has no existing query/ORM instrumentation hook
543
- of any kind yet to extend) and no Redis span capture (no existing Redis hook or dependency exists
544
- here either); a database call or Redis call inside a traced request just won't show up as its own
545
- span for now.
542
+ ### Database spans with their SQL
543
+
544
+ Database queries aren't captured automatically (this SDK has no query or ORM instrumentation hook
545
+ to extend), so wrap a query by hand with `kind: "database"` to have it show up as its own span. Pass
546
+ the SQL as `statement` and ForgeOps shows which query a slow request spent its time in:
547
+
548
+ ```js
549
+ import pg from "pg";
550
+ import * as forgeOpsTracker from "@forge-ops/tracker";
551
+
552
+ const pool = new pg.Pool();
553
+
554
+ app.get("/orders", async (req, res) => {
555
+ const sql = "SELECT id, total FROM orders WHERE customer_id = $1 AND status = 'open'";
556
+ const { rows } = await forgeOpsTracker.span("Load open orders", () => pool.query(sql, [req.user.id]), {
557
+ kind: "database",
558
+ statement: sql,
559
+ dbSystem: "postgresql",
560
+ });
561
+ res.json(rows);
562
+ });
563
+ ```
564
+
565
+ The statement is masked before it's stored on the span: every string and number becomes `?`, so the
566
+ span above carries `SELECT id, total FROM orders WHERE customer_id = $1 AND status = ?`, and
567
+ ForgeOps masks it again on arrival. It's cut to 4000 characters. Bind values (the `[req.user.id]`
568
+ array) are never read or sent. `dbSystem` is optional (`"postgresql"`, `"mysql"`, `"sqlite"`,
569
+ `"mssql"`, `"oracle"`, or any other lowercase name) and both options are ignored on spans of any
570
+ other kind. They go out in the span's data as `db.statement` and `db.system`.
571
+
572
+ **Known gaps:** no automatic database span capture (see above) and no Redis span capture (no
573
+ existing Redis hook or dependency exists here either); a Redis call inside a traced request just
574
+ won't show up as its own span for now.
546
575
 
547
576
  ## Database errors
548
577
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forge-ops/tracker",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "ForgeOps error tracking client: captures unhandled exceptions (Express/Fastify integration, plus explicit capture anywhere else) and delivers them to ForgeOps over HTTP.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -12,6 +12,7 @@ import { Reporter } from "./reporter.js";
12
12
  import { requestStateFor } from "./requestState.js";
13
13
  import { SessionFlusher } from "./sessionFlusher.js";
14
14
  import { randomSpanId, SpanBuffer } from "./spanBuffer.js";
15
+ import { mask as maskSql } from "./sqlStatement.js";
15
16
  import { buildTraceparent } from "./traceParent.js";
16
17
 
17
18
  export { Configuration };
@@ -610,17 +611,30 @@ export function runWithUser(user, callback) {
610
611
  * request-level trace around it, and no middleware left running to ever flush it, would just
611
612
  * accumulate in memory forever with nothing to ever send it, worse than not recording it at all.
612
613
  *
614
+ * A "database" span can also carry the SQL it ran, so ForgeOps can show which query was slow:
615
+ *
616
+ * await forgeOpsTracker.span("Load orders", () => pool.query(sql, [userId]), {
617
+ * kind: "database",
618
+ * statement: sql,
619
+ * dbSystem: "postgresql",
620
+ * });
621
+ *
622
+ * The statement is masked (every string and number replaced by "?") before it's ever stored on
623
+ * the span, and sent as data["db.statement"], with dbSystem lowercased as data["db.system"]. Bind
624
+ * values are never taken, and both options are ignored on any other kind of span.
625
+ *
613
626
  * @template T
614
627
  * @param {string} name
615
628
  * @param {() => T} callback
616
- * @param {{ kind?: string, data?: Record<string, unknown> }} [options]
629
+ * @param {{ kind?: string, data?: Record<string, unknown>, statement?: string, dbSystem?: string }} [options]
617
630
  * @returns {T}
618
631
  */
619
- export function span(name, callback, { kind = "service", data = {} } = {}) {
632
+ export function span(name, callback, { kind = "service", data = {}, statement, dbSystem } = {}) {
620
633
  const buffer = spanStorage.getStore();
621
634
  if (!buffer) {
622
635
  return callback();
623
636
  }
637
+ data = databaseSpanData(kind, data, statement, dbSystem);
624
638
 
625
639
  const spanId = randomSpanId();
626
640
  const parentSpanId = spanParentStorage.getStore() ?? buffer.rootSpanId;
@@ -652,6 +666,33 @@ export function span(name, callback, { kind = "service", data = {} } = {}) {
652
666
  }
653
667
  }
654
668
 
669
+ /**
670
+ * The data a span is recorded with: unchanged unless it's a database span, which gets the masked
671
+ * statement as "db.statement" and the lowercased database name as "db.system". A "db.statement"
672
+ * passed in `data` directly is masked as well, so the raw SQL can't reach the payload either way.
673
+ * @param {string} kind
674
+ * @param {Record<string, unknown>} data
675
+ * @param {string | undefined} statement
676
+ * @param {string | undefined} dbSystem
677
+ * @returns {Record<string, unknown>}
678
+ */
679
+ function databaseSpanData(kind, data, statement, dbSystem) {
680
+ if (kind !== "database") {
681
+ return data;
682
+ }
683
+ const result = { ...data };
684
+ const masked = maskSql(typeof statement === "string" ? statement : result["db.statement"]);
685
+ if (masked === null) {
686
+ delete result["db.statement"];
687
+ } else {
688
+ result["db.statement"] = masked;
689
+ }
690
+ if (typeof dbSystem === "string" && dbSystem.trim() !== "") {
691
+ result["db.system"] = dbSystem.trim().toLowerCase();
692
+ }
693
+ return result;
694
+ }
695
+
655
696
  /**
656
697
  * Reports anything that would otherwise crash the process outright (a
657
698
  * plain script, an unhandled promise rejection) with no further wiring: