Repository: iopipe/iopipe-js-core
Pull request #250 (2018-04-04 20:11:58)

feat(labels): Add .metric and .label syntax
- Adds .metric and .label syntax to support "labeling" invocations.
- Updates current .log method with deprecation notes.
- Adds docs for custom metrics and labels
- Updates README to include a table of contents for quick linking


Patch:
diff --git a/README.md b/README.md
index fefb970..96559d3 100644
--- a/README.md
+++ b/README.md
@@ -6,12 +6,66 @@ IOpipe Analytics & Distributed Tracing Agent
 
 This package provides analytics and distributed tracing for event-driven applications running on AWS Lambda.
 
-# Installation & usage
+- [Installation](#installation)
+- [Usage](#usage)
+  - [Custom Metrics](#custom-metrics)
+  - [Labels](#labels)
+- [Configuration](#configuration)
+  - [Methods](#methods)
+  - [Options](#options)
 
-Install by requiring this module, passing it an object with your project token ([register for access](https://www.iopipe.com)), and it will automatically monitor and collect metrics from your applications running on AWS Lambda.
+# Installation
+
+Install using your package manager of choice,
+
+`npm install @iopipe/core`
+
+or
+
+`yarn add @iopipe/core`
 
 If you are using the Serverless Framework to deploy your lambdas, check out our [serverless plugin](https://github.com/iopipe/serverless-plugin-iopipe).
 
+# Usage
+
+Configure the library with your project token ([register for access](https://www.iopipe.com)), and it will automatically monitor and collect metrics from your applications running on AWS Lambda.
+
+Example:
+
+```js
+const iopipeLib = require('@iopipe/core');
+
+const iopipe = iopipeLib({ token: 'PROJECT_TOKEN' });
+
+exports.handler = iopipe((event, context) => {
+  context.succeed('This is my serverless function!');
+});
+```
+
+## Custom metrics
+
+You may add custom metrics to an invocation using `context.iopipe.metric` to add
+either string or numerical values. Keys have a maximum length of 256 characters, and string values are limited
+to 1024.
+
+Example:
+
+```js
+const iopipeLib = require('@iopipe/core');
+
+const iopipe = iopipeLib({ token: 'PROJECT_TOKEN' });
+
+exports.handler = iopipe((event, context) => {
+  context.iopipe.metric('key', 'some-value');
+  context.iopipe.metric('another-key', 42);
+  context.succeed('This is my serverless function!');
+});
+```
+
+## Labels
+
+You can label invocations using `context.iopipe.label` to label an invocation with a string value, with a limit of 128 characters.
+
 Example:
 
 ```js
@@ -20,6 +74,7 @@ const iopipeLib = require('@iopipe/core');
 const iopipe = iopipeLib({ token: 'PROJECT_TOKEN' });
 
 exports.handler = iopipe((event, context) => {
+  context.iopipe.label('something-important-happened');
   context.succeed('This is my serverless function!');
 });
 ```
@@ -41,7 +96,7 @@ You can configure your iopipe setup through one or more different methods - that
 
 #### `token` (string: required)
 
-If not supplied, the environment variable `$IOPIPE_TOKEN` will be used if present. [Find your project token](https://dashboard.iopipe.com/install)
+If not supplied, the environment variable `$IOPIPE_TOKEN` will be used if present. [Find your project token](https://dashboard.iopipe.com/install).
 
 #### `debug` (bool: optional = false)
 
diff --git a/acceptance/handler.js b/acceptance/handler.js
index 548a76f..9bd10aa 100644
--- a/acceptance/handler.js
+++ b/acceptance/handler.js
@@ -5,20 +5,40 @@ const iopipe = require('../dist/iopipe')({
 
 module.exports.callback = iopipe((event, context, callback) => {
   context.iopipe.log('custom_metric', 'A custom metric for callback');
+  context.iopipe.metric(
+    'custom_metric_with_metric',
+    'A custom metric for callback'
+  );
+  context.iopipe.label('callback');
   callback(null, 'callback');
 });
 
 module.exports.succeed = iopipe((event, context) => {
   context.iopipe.log('custom_metric', 'A custom metric for succeed');
+  context.iopipe.metric(
+    'custom_metric_with_metric',
+    'A custom metric for succeed'
+  );
+  context.iopipe.label('succeed');
   context.succeed('context.succeed');
 });
 
 module.exports.fail = iopipe((event, context) => {
   context.iopipe.log('custom_metric', 'A custom metric for fail');
+  context.iopipe.metric(
+    'custom_metric_with_metric',
+    'A custom metric for fail'
+  );
+  context.iopipe.label('fail');
   context.fail('context.fail');
 });
 
 module.exports.done = iopipe((event, context) => {
   context.iopipe.log('custom_metric', 'A custom metric for done');
+  context.iopipe.metric(
+    'custom_metric_with_metric',
+    'A custom metric for done'
+  );
+  context.iopipe.label('done');
   context.done(null, 'context.done');
 });
diff --git a/src/index.js b/src/index.js
index c5676e7..38f7db2 100644
--- a/src/index.js
+++ b/src/index.js
@@ -3,6 +3,7 @@ import Report from './report';
 import { COLDSTART, VERSION } from './globals';
 import { getDnsPromise } from './dns';
 import { getHook } from './hooks';
+import { convertToString } from './util';
 import setupPlugins from './util/setupPlugins';
 
 /*eslint-disable no-console*/
@@ -48,6 +49,7 @@ class IOpipeWrapperClass {
     this.startTimestamp = Date.now();
     this.config = config;
     this.metrics = [];
+    this.labels = new Set();
     this.originalIdentity = originalIdentity;
     this.event = originalEvent;
     this.originalContext = originalContext;
@@ -62,7 +64,7 @@ class IOpipeWrapperClass {
     // support deprecated iopipe.log
     libFn.log = (...logArgs) => {
       console.warn(
-        'iopipe.log is deprecated and will be removed in a future version, please use context.iopipe.log'
+        'iopipe.log is deprecated and will be removed in a future version, please use context.iopipe.metric'
       );
       this.log(...logArgs);
     };
@@ -83,7 +85,9 @@ class IOpipeWrapperClass {
       fail: this.fail.bind(this),
       done: this.done.bind(this),
       iopipe: {
+        label: this.label.bind(this),
         log: this.log.bind(this),
+        metric: this.metric.bind(this),
         version: VERSION,
         config: this.config
       }
@@ -120,6 +124,11 @@ class IOpipeWrapperClass {
       delete this.originalContext[reset ? `original_${method}` : method];
     });
   }
+  debugLog(message, level = 'warn') {
+    if (this.config.debug) {
+      console[level](message);
+    }
+  }
   invoke() {
     this.runHook('pre:invoke');
     try {
@@ -193,19 +202,46 @@ class IOpipeWrapperClass {
       this.originalContext.done(err, data);
     });
   }
-  log(name, value) {
+  metric(keyInput, valueInput) {
     let numberValue, stringValue;
-    if (typeof value === 'number') {
-      numberValue = value;
+    const key = convertToString(keyInput);
+    if (key.length > 128) {
+      this.debugLog(
+        `Metric with key name ${key} is longer than allowed length of 128, metric will not be saved`
+      );
+      return;
+    }
+    if (Number.isFinite(valueInput)) {
+      numberValue = valueInput;
     } else {
-      stringValue = typeof value === 'string' ? value : JSON.stringify(value);
+      stringValue = convertToString(valueInput);
     }
     this.metrics.push({
-      name,
+      name: key,
       n: numberValue,
       s: stringValue
     });
   }
+  label(name) {
+    if (typeof name !== 'string') {
+      this.debugLog(`Label ${name} is not a string and will not be saved`);
+      return;
+    }
+    if (name.length > 128) {
+      this.debugLog(
+        `Label with name ${name} is longer than allowed length of 128, label will not be saved`
+      );
+      return;
+    }
+    this.labels.add(name);
+  }
+  // DEPRECATED: This method is deprecated in favor of .metric and .label
+  log(name, value) {
+    this.debugLog(
+      'context.iopipe.log is deprecated and will be removed in a future version, please use context.iopipe.metric'
+    );
+    this.metric(name, value);
+  }
 }
 
 module.exports = options => {
diff --git a/src/report.js b/src/report.js
index c925d65..5195ca5 100644
--- a/src/report.js
+++ b/src/report.js
@@ -34,6 +34,7 @@ class Report {
       context = {},
       dnsPromise = Promise.resolve(),
       metrics = [],
+      labels = new Set(),
       plugins = [],
       startTime = process.hrtime(),
       startTimestamp = Date.now()
@@ -115,6 +116,7 @@ class Report {
       errors: {},
       coldstart: COLDSTART,
       custom_metrics: metrics,
+      labels,
       plugins: pluginMetas
     };
 
@@ -203,6 +205,9 @@ class Report {
       durationHrTime[0] * 1e9 + durationHrTime[1]
     );
 
+    // Convert labels from set to array
+    this.report.labels = Array.from(this.report.labels);
+
     if (config.debug) {
       log('IOPIPE-DEBUG: ', JSON.stringify(this.report));
     }
diff --git a/src/util.js b/src/util.js
new file mode 100644
index 0000000..21783e1
--- /dev/null
+++ b/src/util.js
@@ -0,0 +1,6 @@
+export function convertToString(value) {
+  if (typeof value === 'object') {
+    return JSON.stringify(value);
+  }
+  return String(value);
+}


Test patch:
diff --git a/src/__snapshots__/class.test.js.snap b/src/__snapshots__/class.test.js.snap
index 557975b..33c1ee1 100644
--- a/src/__snapshots__/class.test.js.snap
+++ b/src/__snapshots__/class.test.js.snap
@@ -30,7 +30,7 @@ Array [
   Object {
     "n": undefined,
     "name": "metric-6",
-    "s": undefined,
+    "s": "undefined",
   },
   Object {
     "n": undefined,
@@ -105,3 +105,50 @@ Array [
   ],
 ]
 `;
+
+exports[`ctx.iopipe.label adds labels to the labels array 1`] = `
+Array [
+  "label-1",
+  "label-2",
+]
+`;
+
+exports[`ctx.iopipe.metric adds metrics to the custom_metrics array 1`] = `
+Array [
+  Object {
+    "n": undefined,
+    "name": "metric-1",
+    "s": "foo",
+  },
+  Object {
+    "n": undefined,
+    "name": "metric-2",
+    "s": "true",
+  },
+  Object {
+    "n": undefined,
+    "name": "metric-3",
+    "s": "{\\"ding\\":\\"dong\\"}",
+  },
+  Object {
+    "n": undefined,
+    "name": "metric-4",
+    "s": "[\\"whoa\\"]",
+  },
+  Object {
+    "n": 100,
+    "name": "metric-5",
+    "s": undefined,
+  },
+  Object {
+    "n": undefined,
+    "name": "metric-6",
+    "s": "NaN",
+  },
+  Object {
+    "n": undefined,
+    "name": "metric-7",
+    "s": "undefined",
+  },
+]
+`;
diff --git a/src/class.test.js b/src/class.test.js
index 093a197..93a9cfb 100644
--- a/src/class.test.js
+++ b/src/class.test.js
@@ -189,6 +189,77 @@ test('Allows ctx.iopipe.log and iopipe.log functionality', async () => {
   }
 });
 
+test('ctx.iopipe.metric adds metrics to the custom_metrics array', async () => {
+  expect.assertions(4);
+  try {
+    const iopipe = createAgent({});
+    const wrappedFunction = iopipe(function Wrapper(event, ctx) {
+      ctx.iopipe.metric('metric-1', 'foo');
+      ctx.iopipe.metric('metric-2', true);
+      ctx.iopipe.metric('metric-3', { ding: 'dong' });
+      ctx.iopipe.metric('metric-4', ['whoa']);
+      ctx.iopipe.metric('metric-5', 100);
+      // NaN is saved as string
+      ctx.iopipe.metric('metric-6', Number('foo'));
+      ctx.iopipe.metric('metric-7');
+      // This is too long to be added
+      ctx.iopipe.metric(
+        new Array(130).join('a'),
+        'value not saved because key too long'
+      );
+      ctx.succeed('all done');
+    });
+
+    const context = mockContext({ functionName: 'metric-test' });
+    wrappedFunction({}, context);
+    const val = await context.Promise;
+    expect(val).toEqual('all done');
+
+    const metrics = _.chain(reports)
+      .find(obj => obj.aws.functionName === 'metric-test')
+      .get('custom_metrics')
+      .value();
+    expect(_.isArray(metrics)).toBe(true);
+    expect(metrics).toHaveLength(7);
+    expect(metrics).toMatchSnapshot();
+  } catch (err) {
+    throw err;
+  }
+});
+
+test('ctx.iopipe.label adds labels to the labels array', async () => {
+  expect.assertions(4);
+  try {
+    const iopipe = createAgent({});
+    const wrappedFunction = iopipe(function Wrapper(event, ctx) {
+      ctx.iopipe.label('label-1');
+      ctx.iopipe.label('label-2');
+      // Non-strings are dropped
+      ctx.iopipe.label(2);
+      ctx.iopipe.label({ foo: 'bar' });
+      // This label is too long to be added
+      ctx.iopipe.label(new Array(130).join('a'));
+      ctx.succeed('all done');
+    });
+
+    const context = mockContext({ functionName: 'label-test' });
+    wrappedFunction({}, context);
+    const val = await context.Promise;
+    expect(val).toEqual('all done');
+
+    const labels = _.chain(reports)
+      .find(obj => obj.aws.functionName === 'label-test')
+      .get('labels')
+      .value();
+
+    expect(_.isArray(labels)).toBe(true);
+    expect(labels).toHaveLength(2);
+    expect(labels).toMatchSnapshot();
+  } catch (err) {
+    throw err;
+  }
+});
+
 test('Does not have context.iopipe.log collisions', async () => {
   try {
     const iopipe = createAgent({
diff --git a/src/report.test.js b/src/report.test.js
index d80e3fa..41fd239 100644
--- a/src/report.test.js
+++ b/src/report.test.js
@@ -57,7 +57,6 @@ describe('Report creation', () => {
         'environment.python.version',
         'errors.stackHash',
         'errors.count',
-        'labels',
         'performanceEntries.0.name',
         'performanceEntries.0.startTime',
         'performanceEntries.0.duration',
diff --git a/src/util.test.js b/src/util.test.js
new file mode 100644
index 0000000..e2b10f9
--- /dev/null
+++ b/src/util.test.js
@@ -0,0 +1,13 @@
+import { convertToString } from './util';
+
+test('converts to string', () => {
+  expect(convertToString('Foo')).toBe('Foo');
+  expect(convertToString(undefined)).toBe('undefined');
+  expect(convertToString(NaN)).toBe('NaN');
+  expect(convertToString({ foo: 1 })).toBe('{"foo":1}');
+  expect(convertToString([1, 2])).toBe('[1,2]');
+  expect(convertToString(true)).toBe('true');
+  expect(convertToString(100)).toBe('100');
+  expect(convertToString(null)).toBe('null');
+  expect(convertToString(Symbol('foo'))).toBe('Symbol(foo)');
+});