Repository: serverless/dashboard (language: python)

Issue:
SDK: Define `Span.custom_tags` to host custom user tags



Discussion / hints:

Fix patch:
diff --git a/node/package.json b/node/package.json
index 37626ec2a..2cd37cc74 100644
--- a/node/package.json
+++ b/node/package.json
@@ -57,7 +57,7 @@
     "log-node": "^8.0.3",
     "memoizee": "^0.4.15",
     "mocha": "^9.2.2",
-    "ncjsm": "^4.3.1",
+    "ncjsm": "^4.3.2",
     "node-fetch": "^2.6.7",
     "node-machine-id": "^1.1.12",
     "prettier": "^2.8.2",
diff --git a/node/packages/sdk/.ts-types/index.d.ts b/node/packages/sdk/.ts-types/index.d.ts
index 2e7fef597..f418b3d9f 100644
--- a/node/packages/sdk/.ts-types/index.d.ts
+++ b/node/packages/sdk/.ts-types/index.d.ts
@@ -26,6 +26,7 @@ export interface Sdk {
       tags?: Record<string, boolean | number | string | Date | Array<unknown> | null>;
     }
   ): undefined;
+  setTag(name: string, value: boolean | number | string | Date | Array<unknown> | null): undefined;
 }
 
 export interface SdkOptions {
diff --git a/node/packages/sdk/docs/sdk.md b/node/packages/sdk/docs/sdk.md
index 94654ce78..332266688 100644
--- a/node/packages/sdk/docs/sdk.md
+++ b/node/packages/sdk/docs/sdk.md
@@ -41,3 +41,10 @@ Record warning
 - `options`:
   - `tags` _(object)_ - User tags object. Tag names can contain alphanumeric (both lower and upper case), `-`, `_` and `.` characters. Values can be _string_, _boolean_, _number_, Date or Array containing any values of prior listed types
   - `fingerprint` _(string)_ - Console UI groups common warnings by the _fingerprint_, which by default is derived from its message. This can be overriden by passing custom `fingeprint` value
+
+### `.setTag(name, value)`
+
+Set custom (user defined) tag on root span
+
+- `name` _(string)_ - Tag name, can contain alphanumeric (both lower and upper case), `-`, `_` and `.` characters
+- `value` (any) - Tag value. Can be _string_, _boolean_, _number_, _Date_ or _Array_ containing any values of prior listed types
diff --git a/node/packages/sdk/index.js b/node/packages/sdk/index.js
index 0fade09e0..37e833fdb 100644
--- a/node/packages/sdk/index.js
+++ b/node/packages/sdk/index.js
@@ -23,7 +23,11 @@ const serverlessSdk = module.exports;
 // Public
 serverlessSdk.name = pkgJson.name;
 serverlessSdk.version = pkgJson.version;
-serverlessSdk.traceSpans = {};
+serverlessSdk.traceSpans = {
+  get root() {
+    return TraceSpan.rootSpan;
+  },
+};
 serverlessSdk.instrumentation = {};
 Object.defineProperties(
   serverlessSdk.instrumentation,
@@ -37,6 +41,12 @@ serverlessSdk.captureError = (error, options = {}) => {
 serverlessSdk.captureWarning = (message, options = {}) => {
   createWarningCapturedEvent(message, options);
 };
+serverlessSdk.setTag = (name, value) => {
+  if (!serverlessSdk.traceSpans.root) {
+    throw new Error('Cannot set tag with no root trace span being initialized');
+  }
+  serverlessSdk.traceSpans.root.customTags.set(name, value);
+};
 
 // Private
 const settings = (serverlessSdk._settings = {});
diff --git a/node/packages/sdk/lib/trace-span.js b/node/packages/sdk/lib/trace-span.js
index aea66c1c8..53ecf21fa 100644
--- a/node/packages/sdk/lib/trace-span.js
+++ b/node/packages/sdk/lib/trace-span.js
@@ -17,6 +17,8 @@ const generateId = require('./generate-id');
 const resolveEpochTimestampString = require('./resolve-epoch-timestamp-string');
 const toProtobufEpochTimestamp = require('./to-protobuf-epoch-timestamp');
 
+const objHasOwnProperty = Object.prototype.hasOwnProperty;
+
 class StringifiableSet extends Set {
   toJSON() {
     return Array.from(this);
@@ -25,11 +27,9 @@ class StringifiableSet extends Set {
 
 const asyncLocalStorage = new AsyncLocalStorage();
 
-let rootSpan;
-
 class TraceSpan {
   static resolveCurrentSpan() {
-    return asyncLocalStorage.getStore() || rootSpan || null;
+    return asyncLocalStorage.getStore() || TraceSpan.rootSpan || null;
   }
 
   constructor(name, options = {}) {
@@ -61,17 +61,19 @@ class TraceSpan {
     if (tags) this.tags.setMany(tags);
     if (options.input != null) this.input = options.input;
     if (options.output != null) this.output = options.output;
-    if (!rootSpan) {
-      rootSpan = this;
+    if (!TraceSpan.rootSpan) {
+      TraceSpan.rootSpan = this;
       this.parentSpan = null;
     } else {
-      if (rootSpan.endTime) {
+      if (TraceSpan.rootSpan.endTime) {
         throw Object.assign(new Error('Cannot intialize span: Trace is closed'), {
           code: 'UNREACHABLE_TRACE',
         });
       }
       this.parentSpan = TraceSpan.resolveCurrentSpan();
-      while (this.parentSpan.endTime) this.parentSpan = this.parentSpan.parentSpan || rootSpan;
+      while (this.parentSpan.endTime) {
+        this.parentSpan = this.parentSpan.parentSpan || TraceSpan.rootSpan;
+      }
     }
 
     asyncLocalStorage.enterWith(this);
@@ -88,13 +90,13 @@ class TraceSpan {
   }
   closeContext() {
     if (asyncLocalStorage.getStore() !== this) return;
-    if (this === rootSpan) {
+    if (this === TraceSpan.rootSpan) {
       asyncLocalStorage.enterWith(this);
       return;
     }
     const openParentSpan = (function self(span) {
       if (!span.endTime) return span;
-      return span.parentSpan ? self(span.parentSpan) : rootSpan;
+      return span.parentSpan ? self(span.parentSpan) : TraceSpan.rootSpan;
     })(this.parentSpan);
     asyncLocalStorage.enterWith(openParentSpan);
   }
@@ -121,7 +123,7 @@ class TraceSpan {
       }
     }
     this.endTime = targetEndTime || defaultEndTime;
-    if (this === rootSpan) {
+    if (this === TraceSpan.rootSpan) {
       const leftoverSpans = [];
       for (const subSpan of this.spans) {
         if (subSpan.endTime) continue;
@@ -157,6 +159,7 @@ class TraceSpan {
       input: this.input || undefined,
       output: this.output || undefined,
       tags: this.tags,
+      customTags: objHasOwnProperty.call(this, 'customTags') ? this.customTags : undefined,
     };
   }
   toProtobufObject() {
@@ -170,6 +173,9 @@ class TraceSpan {
       input: this.input || undefined,
       output: this.output || undefined,
       tags: toProtobufTags(this.tags),
+      customTags: objHasOwnProperty.call(this, 'customTags')
+        ? JSON.stringify(this.customTags)
+        : undefined,
     };
   }
   get spans() {
@@ -205,6 +211,7 @@ Object.defineProperties(
     id: d(() => generateId()),
     subSpans: d(() => new Set()),
     tags: d(() => new Tags()),
+    customTags: d(() => new Tags()),
   })
 );
 


Test patch:
diff --git a/node/packages/sdk/test/unit/index.test.js b/node/packages/sdk/test/unit/index.test.js
index 022773da0..3656a198c 100644
--- a/node/packages/sdk/test/unit/index.test.js
+++ b/node/packages/sdk/test/unit/index.test.js
@@ -2,11 +2,23 @@
 
 const { expect } = require('chai');
 
+const requireUncached = require('ncjsm/require-uncached');
+
 describe('index.test.js', () => {
   let serverlessSdk;
+  let rootSpan;
   before(() => {
-    serverlessSdk = require('../../');
+    requireUncached(() => {
+      const TraceSpan = require('../../lib/trace-span');
+      serverlessSdk = require('../../');
+      rootSpan = new TraceSpan('test');
+    });
+  });
+  after(() => {
+    rootSpan.close();
+    delete require('uni-global')('serverless/sdk/202212').serverlessSdk;
   });
+  before(() => {});
   it('should expose `.traceSpans`', () =>
     expect(serverlessSdk.traceSpans).to.be.instanceOf(Object));
   it('should expose .instrumentation.expressApp', () =>
@@ -27,4 +39,9 @@ describe('index.test.js', () => {
     expect(capturedEvent.tags.get('warning.message')).to.equal('Warning message');
     expect(capturedEvent.customTags.get('user.tag')).to.equal('warningvalue');
   });
+
+  it('should expose .setTag', () => {
+    serverlessSdk.setTag('tag', 'value');
+    expect(rootSpan.customTags.get('tag')).to.equal('value');
+  });
 });
diff --git a/node/packages/sdk/test/unit/lib/trace-span.test.js b/node/packages/sdk/test/unit/lib/trace-span.test.js
index f5bb86e82..8cbd63647 100644
--- a/node/packages/sdk/test/unit/lib/trace-span.test.js
+++ b/node/packages/sdk/test/unit/lib/trace-span.test.js
@@ -128,6 +128,7 @@ describe('lib/trace-span.test.js', () => {
   it('should stringify to JSON', () => {
     const childSpan = new TraceSpan('child');
     childSpan.tags.set('foo', 12);
+    childSpan.customTags.set('elo', 'marko');
     childSpan.close();
     const jsonValue = JSON.parse(JSON.stringify(childSpan));
     // Validate bigint valid values
@@ -141,6 +142,7 @@ describe('lib/trace-span.test.js', () => {
       startTime: jsonValue.startTime,
       endTime: jsonValue.endTime,
       tags: { foo: 12 },
+      customTags: { elo: 'marko' },
     });
   });
 
@@ -156,6 +158,7 @@ describe('lib/trace-span.test.js', () => {
     childSpan.tags.set('some.number', 123);
     childSpan.tags.set('some.strings', ['foo', 'bar']);
     childSpan.tags.set('some.numbers', [12, 23]);
+    childSpan.customTags.set('elo', 'marko');
     childSpan.close();
     const protoJson = childSpan.toProtobufObject();
     expect(protoJson).to.deep.equal({
@@ -178,6 +181,7 @@ describe('lib/trace-span.test.js', () => {
           numbers: [new Long(12, 0, true), new Long(23, 0, true)],
         },
       },
+      customTags: '{"elo":"marko"}',
     });
     expect(Long.isLong(protoJson.startTimeUnixNano)).to.be.true;
     expect(Long.isLong(protoJson.endTimeUnixNano)).to.be.true;