Instrumenting a JavaScript application for OpenTelemetry, Part 1: Setup

A green background with a computer and opentelemetry instrumentation gears on it.
ACF Image Blog

This post looks at the first steps for instrumenting a JavaScript application to report OpenTelemetry metrics.

Chronosphere staff
Chris Ward
6 MINS READ

An introduction to metrics data

An introduction to instrumentation
A lot of what you read around the topic of Observability mentions the benefits and potential of analyzing data, but little about how you collect it. This process is called “instrumentation” and broadly involves collecting events in infrastructure and code that include metrics, logs, and traces.

There are of course dozens of methods, frameworks, and tools to help you collect the events that are important to you, and this post begins a series looking at some of those. This post focuses on introductory concepts, setting up the dependencies needed, and generating some basic metrics. Later posts will take these concepts further.

Different vendors and open source projects created their own ways to represent the event data they collect. While this remains true, there are increased efforts to create portable standards that everyone can use and add their features on top of but retain interoperability. The key project is OpenTelemetry from the Cloud Native Computing Foundation (CNCF). This blog series will use the OpenTelemetry specification and SDKs, but collect and export a variety of the formats it handles.

The application example

The example for this post is an ExpressJS application that serves API endpoints and exports Prometheus-compatible metrics. The tutorial starts by adding basic instrumentation and sending metrics to a Prometheus backend, then adds more, and adds the Chronosphere collector. You can find the full and final code on GitHub.

Install and setup ExpressJS

ExpressJS provides a lot of boilerplate for creating a JavaScript application that serves HTTP endpoints, so is a great start point. Add it to a new project by following the install steps.

Create an app.js file and create the basic skeleton for the application:

const express = require("express");
const PORT = process.env.PORT || "3000";
const app = express();
app.get("/", (req, res) => {
  res.send("Hello World");
});
app.listen(parseInt(PORT, 10), () => {
  console.log(`Listening for requests on https://localhost:${PORT}`);
});

Running this now with node app.js starts a server on port 3000. If you visit localhost:3000 you see the message “Hello World” in the web browser.

Add basic metrics

This step uses the tutorial from the OpenTelemetry site as a basis with some changes, and builds upon it in later steps.

Install the dependencies the project needs, which are the Prometheus exporter, and the base metrics SDK.

npm install --save @opentelemetry/sdk-metrics-base
npm install --save @opentelemetry/exporter-prometheus

Create a new monitoring.js file to handle the metrics functions and add the dependencies:

const { PrometheusExporter } = require('@opentelemetry/exporter-prometheus');
const { MeterProvider }  = require('@opentelemetry/sdk-metrics-base');

Create an instance of a MeterProvider that uses the Prometheus exporter. To prevent conflicts with ports, the exporter uses a different port. Typically Prometheus runs on port 9090, but as the Prometheus server runs on the same machine for this example, use port 9091 instead.

const meter = new MeterProvider({
  exporter: new PrometheusExporter({port: 9091}),
}).getMeter('prometheus');

Create the metric to manually track, which in this case is a counter of the number of visits to a page.

const requestCount = meter.createCounter("requests", {
  description: "Count all incoming requests",
  monotonic: true,
  labelKeys: ["metricOrigin"],
});

Create a Map of the values based on the route (which in this case, is only one) and create an exportable function that increments the count each time a route is requested.

In the app.js file, require the countAllRequests function, and add with Express’s .use middleware function, call it on every request.

const { countAllRequests } = require("./monitoring");
…
app.use(countAllRequests());

At this point you can start Express and check that the application is emitting metrics. Run the command below and refresh localhost:3000 a couple of times.

node app.js

Open localhost:9091/metrics and you should see a list of the metrics emitted so far.

Install and configure Prometheus

Install Prometheus and create a configuration file with the following content:

global:
  scrape_interval: 15s
# Scraping Prometheus itself
scrape_configs:
- job_name: 'prometheus'
  scrape_interval: 5s
  static_configs:
  - targets: ['localhost:9090']
  # Not needed when running with Kubernetes
- job_name: 'express'
  scrape_interval: 5s
  static_configs:
  - targets: ['localhost:9091']

Start Prometheus:

prometheus --config.file=prom-conf.yml

Start Express and refresh localhost:3000 a couple of times.

node app.js

Open the Prometheus UI at localhost:9090, enter requests_total into the search bar and you should see results.

Add Kubernetes to the mix

So far, so good, but Prometheus is more useful when also monitoring the underlying infrastructure running an application, so the next step is to run Express and Prometheus on Kubernetes.

Create a Docker image

The express application needs a custom image, create a Dockerfile and add the following:

FROM node
 
WORKDIR /opt/ot-express
# install deps
COPY package.json /opt/ot-express
RUN npm install
# Setup workdir
COPY . /opt/ot-express
# run
EXPOSE 3000
CMD ["npm", "start"]

Build the image with:

docker build -t ot-express .

Download the Kubernetes definition file from the GitHub repo for this post.

A lot of the configuration is necessary to give Prometheus permission to scrape Kubernetes endpoints, the configuration more unique to this example is the following:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ot-express
spec:
  replicas: 1
  selector:
    matchLabels:
      app: ot-express  
  template:
    metadata:
      labels:
        app: ot-express  
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "9091"
    spec:
      containers: 
      - name: ot-express 
        image: ot-express
        imagePullPolicy: Never
        ports:
        - name: express-app
          containerPort: 3000
        - name: express-metrics
          containerPort: 9091
---
apiVersion: v1
kind: Service
metadata:
  name: ot-express
  labels:
    app: ot-express
spec:
  ports:
  - name: express-app
    port: 3000
    targetPort: express-app
  - name: express-metrics
    port: 9091
    targetPort: express-metrics
  selector:
    app: ot-express
  type: NodePort

This deployment uses annotations to inform Prometheus to scrape metrics from applications in the deployment, and exposes the express and Prometheus ports it uses.

Update the Prometheus configuration to include scraping metrics from Kubernetes-discovered endpoints. This means you can remove the previous Express job.

global:
  scrape_interval: 15s
scrape_configs:
- job_name: 'prometheus'
  scrape_interval: 5s
  static_configs:
  - targets: ['localhost:9090']
- job_name: 'kubernetes-service-endpoints'
  kubernetes_sd_configs:
  - role: endpoints
  relabel_configs:
  - action: labelmap
    regex: __meta_kubernetes_service_label_(.+)
  - source_labels: [__meta_kubernetes_namespace]
    action: replace
    target_label: kubernetes_namespace
  - source_labels: [__meta_kubernetes_service_name]
    action: replace
    target_label: kubernetes_name

Create a ConfigMap of the Prometheus configuration:

kubectl create configmap prometheus-config --from-file=prom-conf.yml

Send the Kubernetes declaration to the server with:

kubectl apply -f k8s-local.yml

Find the exposed URL and port for the Express service, open and refresh the page a few times. Find the exposed URL and port for the Prometheus UI, enter requests_total into the search bar and you should see results.

Increasing application complexity

The demo application works and sends metrics when run on the host machine, Docker, or Kubernetes. But it’s not complex, and doesn’t send that many useful metrics. While still not production-level complex, this example application from the ExpressJS website adds multiple routes and HTTP protocols.

Adding in the other code the demo application needs, update app.js to the following:

const express = require("express");
const { countAllRequests } = require("./monitoring");
const PORT = process.env.PORT || "3000";
const app = express();
app.use(countAllRequests());
function error(status, msg) {
  var err = new Error(msg);
  err.status = status;
  return err;
}
app.use('/api', function(req, res, next){
  var key = req.query['api-key'];
  if (!key) return next(error(400, 'api key required'));
  if (apiKeys.indexOf(key) === -1) return next(error(401, 'invalid api key'))
  req.key = key;
  next();
});
var apiKeys = ['foo', 'bar', 'baz'];
var repos = [
  { name: 'express', url: 'https://github.com/expressjs/express' },
  { name: 'stylus', url: 'https://github.com/learnboost/stylus' },
  { name: 'cluster', url: 'https://github.com/learnboost/cluster' }
];
var users = [
  { name: 'tobi' }
  , { name: 'loki' }
  , { name: 'jane' }
];
var userRepos = {
  tobi: [repos[0], repos[1]]
  , loki: [repos[1]]
  , jane: [repos[2]]
};
app.get('/api/users', function(req, res, next){
  res.send(users);
});
app.get('/api/repos', function(req, res, next){
  res.send(repos);
});
app.get('/api/user/:name/repos', function(req, res, next){
  var name = req.params.name;
  var user = userRepos[name];
  if (user) res.send(user);
  else next();
});
app.use(function(err, req, res, next){
  res.status(err.status || 500);
  res.send({ error: err.message });
});
app.use(function(req, res){
  res.status(404);
  res.send({ error: "Sorry, can't find that" })
});
app.listen(parseInt(PORT, 10), () => {
    console.log(`Listening for requests on https://localhost:${PORT}`);
  });

There are a lot of different routes to try (read the comments in the original code), but here are a couple (open them more than once):

  • https://localhost:3000/api
  • https://localhost:3000/api/users/?api-key=foo
  • https://localhost:3000/api/repos/?api-key=foo
  • https://localhost:3000/api/user/tobi/repos/?api-key=foo

Start the application with Docker as above, and everything works the same, but with more metrics scraped by Prometheus.

If you’re interested in scraping more Express-related metrics, you can try the express-prom-bundle package. If you do, you need to change the port in the Prometheus configuration, Docker and Kubernetes declarations to the Express port, i.e. “3000”. You also no longer need the monitoring.js file, or the countAllRequests methods. Read the documentation for the package for more ways to customize it for generating metrics important to you.

Adding Chronosphere as a backend

Chronosphere is a drop-in scalable back-end for Prometheus, book a live demo to see more.

If you’re already a customer, then you can download the collector configuration file that determines how Chronosphere collects your metrics dataa, and add the domain of your instance and API key as SHA-256 encoded values to the Kubernetes Secret declaration:

apiVersion: v1
data:
  address: {SUB_DOMAIN}
  api-token: {API_TOKEN}
kind: Secret
metadata:
  labels:
    app: chronocollector
  name: chronosphere-secret
  namespace: default
type: Opaque

Follow the same steps for starting the application and Prometheus:

kubectl create configmap prometheus-config --from-file=prom-conf.yml
kubectl apply -f k8s-local.yml

Apply the Chronosphere collector definition:

kubectl apply -f chronocollector.yaml

Again, refresh the application page a few times, and take a look in a dashboard or the metrics profiler in Chronosphere and you should see the Express metric.

Next steps

This post showed you how to setup a JavaScript application to collect OpenTelemetry data using the Prometheus collector and send basic metrics data. Future posts will dig into the metrics and how to apply them to an application in more detail.

Share This: