Instrumenting Browser JavaScript Apps | Honeycomb

Instrumenting Browser JavaScript Apps

If you send data about your web applications to Honeycomb, you probably already know to “start at the edge” and instrument each request as it reaches your servers.

OpenTelemetry in the Browser 

OpenTelemetry can be used in the browser to trace your frontend application. Although auto-instrumentation for the web exists, you will get the most value by supplementing auto-generated instrumentation in your application with custom spans and events. To add custom spans and events, which is known as manual instrumentation, you can use the full OpenTelemetry API.

Even if you only use OpenTelemetry’s auto-instrumentation, because auto-instrumentation generates a significant number of spans, you may want to filter out the span events in a collector using the OpenTelemetry Collector filter processor.

To learn about the tradeoffs between automatic instrumentation and manual instrumentation, read our whitepaper, “Getting Started With Honeycomb Client-Side Instrumentation for Browser Applications”.

OpenTelemetry browser traces are sent via OTLP with HTTP/JSON. Sensitive data is never safe in the browser. We recommend not exposing your Honeycomb API keys in the browser application’s code. This means you will need some kind of proxy to accept any browser traces before you send them to Honeycomb. The most common and recommended solution is to use an OpenTelemetry Collector. By using a collector, you can store sensitive credentials there and ensure that any data being sent to Honeycomb is processed safely (along with any additional data control enabled by the collector). This setup is documented below.

Installing Instrumentation Packages 

To instrument your Web page using OpenTelemetry, you need to install the core OpenTelemetry API, SDK, exporter, and span processor packages:

npm install --save \
    @opentelemetry/api \
    @opentelemetry/sdk-trace-web \
    @opentelemetry/exporter-trace-otlp-http \
    @opentelemetry/context-zone
yarn add \
    @opentelemetry/api \
    @opentelemetry/sdk-trace-web \
    @opentelemetry/exporter-trace-otlp-http \
    @opentelemetry/context-zone

These packages include:

  • api: An API package used to add instrumentation for everything you care about in your application.
  • sdk-trace-web: An SDK package, which creates traces that conform to the OpenTelemetry specification.
  • exporter-trace-otlp-http: An exporter package, which is responsible for sending trace data out via HTTP requests.
  • context-zone: A helper package that processes spans from across your application to ensure they have all the relevant context for rich telemetry.

Initializing 

The OpenTelemetry initialization needs to happen as early as possible in the webpage. Accomplish this by creating an initialization file, similar to the JavaScript example below.

// tracing.js
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { WebTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-web';
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { Resource }  from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';

// The exporter is responsible for sending traces from the browser to your collector
const exporter = new OTLPTraceExporter({
  url: 'https://<your collector endpoint>:443/v1/traces'
});
// The TracerProvider is the core library for creating traces
const provider = new WebTracerProvider({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: 'browser',
  }),
});
// The processor sorts through data as it comes in, before it is sent to the exporter
provider.addSpanProcessor(new BatchSpanProcessor(exporter));
// A context manager allows OTel to keep the context of function calls across async functions
// ensuring you don't have disconnected traces
provider.register({
  contextManager: new ZoneContextManager()
});

Then, load the initialization file at the top of your web page’s header or entry point file. This file needs to be loaded as early as possible in the page to ensure that you can capture as much data about pageload and timing as possible.

// index.js
import './tracing.js'

// ...rest of the app's entry point code

Note that the tracing.js example above calls the BatchSpanProcessor. Batching up span sending operations reduces client-side load and can help reduce battery and data usage from end user devices. Batching will also decrease contention with your application for network resources and ensure that your instrumentation creates minimal impact. The BatchSpanProcessor automatically batches events together.

You then need to add instrumentation for actions. Here, we show sample code that instruments the window load event and a button:

import { trace, context, } from '@opentelemetry/api';

const tracer = trace.getTracer();

const rootSpan = tracer.startActiveSpan('document_load', span => {
  //start span when navigating to page
  span.setAttribute('pageUrlwindow', window.location.href);
  window.onload = (event) => {
    // ... do loading things
    // ... attach timing information
    span.end(); //once page is loaded, end the span
  };

  button.clicked = (event) => {
    tracer.startActiveSpan('button_clicked', btnSpan => {
      // Add your attributes to describe the button clicked here
      btnSpan.setAttribute('some.attribute', 'some.value');

      btnSpan.end();
    });
  }
});

Connecting the Frontend and Backend Traces 

You can connect your frontend request traces to your backend traces, which allows you to trace a request all the way from your browser through your distributed system. To connect your frontend traces to your backend traces, you must include the trace context header in the request. You can do this using either automatic instrumentation or manual instrumentation.

Automatically Propagate the Trace Context Header 

Use request automatic instrumentation to automatically send spans for every HTTP request. @opentelemetry/instrumentation-xml-http-request automatically instruments XHR requests and @opentelemetry/instrumentation-fetch automatically instruments fetch requests. If your browser application uses a request library to make requests, such as axios or superagent, these requests are also automatically instrumented by enabling the xml-http-request or fetch instrumentation, depending on what the library uses to make requests.

npm install --save \
    @opentelemetry/instrumentation \
    @opentelemetry/instrumentation-xml-http-request \
    @opentelemetry/instrumentation-fetch
yarn add \
    @opentelemetry/instrumentation-xml-http-request \
    @opentelemetry/instrumentation-fetch

If utilizing the instrumentation-xml-http-request package or the instrumentation-fetch package, propagate the trace header automatically by setting this configuration property.

import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { registerInstrumentations } from '@opentelemetry/instrumentation';

// ...general opentelemetry configuration

registerInstrumentations({
  instrumentations: [
    new XMLHttpRequestInstrumentation({
      propagateTraceHeaderCorsUrls: [
         /.+/g, //Regex to match your backend urls. This should be updated.
      ]
    }),
    new FetchInstrumentation({
      propagateTraceHeaderCorsUrls: [
         /.+/g, //Regex to match your backend urls. This should be updated.
      ]
    }),
  ],
});

Alternatively, if using the automatic instrumentation package for web, you can send this configuration property to the package:

registerInstrumentations({
 instrumentations: [
   getWebAutoInstrumentations({
     // load custom configuration for xml-http-request instrumentation
     '@opentelemetry/instrumentation-xml-http-request': {
       propagateTraceHeaderCorsUrls: [
           /.+/g, //Regex to match your backend urls. This should be updated.
         ],
     },
     '@opentelemetry/instrumentation-fetch': {
       propagateTraceHeaderCorsUrls: [
           /.+/g, //Regex to match your backend urls. This should be updated.
         ],
     },
   }),
 ],
});

Manually Propagate the Trace Context Header 

It is also possible to manually propagate the trace context header if automatic instrumentation is not an option:

// General request handler, instrumented with OTel
// Forwards traceparent header to connect spans created in the browser
// with spans created on the backend
const request = async (url, method = 'GET', headers, body) => {
  return trace
    .getTracer('request-tracer')
    .startActiveSpan(`Request: ${method} ${url}`, async (span) => {

      // construct W3C traceparent header
      const traceparent = `00-${span.spanContext().traceId}-${span.spanContext().spanId}-01`;

      try {
        const response = await fetch(url, {
          method,
          headers: {
            ...headers,
            // set traceparent header
            traceparent: traceparent,
          },
          body,
        });
        span.setAttributes({
          'http.method': method,
          'http.url': url,
          'response.status_code': response.status,
        });
        if (response.ok && response.status >= 200 && response.status < 400) {
          span.setStatus({ code: SpanStatusCode.OK });
          return response.text();
        } else {
          throw new Error(`Request Error ${response.status} ${response.statusText}`);
        }
      } catch (error) {
        span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
        throw new Error(error);
      } finally {
        span.end();
      }
    });
};

Other Automatic Instrumentation 

There are several automatic instrumentation libraries available for the browser. They tend to produce a lot of span events so we recommend adding them with some caution.

npm install --save \
    @opentelemetry/instrumentation-document-load \
    @opentelemetry/instrumentation-user-interaction \
    @opentelemetry/instrumentation-long-task
yarn add \
    @opentelemetry/instrumentation-document-load \
    @opentelemetry/instrumentation-user-interaction \
    @opentelemetry/instrumentation-long-task

Document Load 

@opentelemetry/instrumentation-document-load produces spans for document load information for the browser, including how long it takes the document to load and become interactive. It also creates child spans for each resource fetched by the browser. For example, CSS files, JS files, fonts and images.

import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load';


// ...general opentelemetry configuration

registerInstrumentations({
  instrumentations: [
    new DocumentLoadInstrumentation(),
  ],
});

User Interaction 

@opentelemetry/instrumentation-user-interaction produces spans for user interactions with a browser web application.

import { UserInteractionInstrumentation } from '@opentelemetry/instrumentation-user-interaction';


// ...general opentelemetry configuration

registerInstrumentations({
  instrumentations: [
    new UserInteractionInstrumentation(),
  ],
});

By default, only click events are automatically instrumented. To automatically instrument other events, specify the events that should be captured for telemetry. Most browser events are supported.

import { UserInteractionInstrumentation } from '@opentelemetry/instrumentation-user-interaction';


// ...general opentelemetry configuration

registerInstrumentations({
  instrumentations: [
    new UserInteractionInstrumentation({
      eventNames: ['submit', 'click', 'keypress'],
    }),
  ],
});

To attach extra attributes to user interaction spans, provide a callback function:

import { UserInteractionInstrumentation } from '@opentelemetry/instrumentation-user-interaction';
import { registerInstrumentations } from '@opentelemetry/instrumentation';


// ...general opentelemetry configuration

registerInstrumentations({
  instrumentations: [
    new UserInteractionInstrumentation({
      eventNames: ['submit', 'click', 'keypress'],
      shouldPreventSpanCreation: (event, element, span) => {
        span.setAttribute('target.id', element.id)
        // etc..
      }
    }),
  ],
});

Long Tasks 

@opentelemetry/instrumentation-long-task provides automatic instrumentation for Long Task API.

import { UserInteractionInstrumentation } from '@opentelemetry/instrumentation-user-interaction';

// ...general opentelemetry configuration

registerInstrumentations({
  tracerProvider: provider,
  instrumentations: [
    new LongTaskInstrumentation({
      observerCallback: (span, longtaskEvent) => {
        span.setAttribute('location.pathname', window.location.pathname)
      }
    }),
  ],
});

More on Manual Instrumentation 

The OpenTelemetry documentation for JavaScript has a comprehensive set of topics on manual instrumentation.

Configuration Options for Browser Telemetry 

Browser Code Configuration (Exposing Your Key) 

Browser code that sends requests directly to Honeycomb requires an API key. This exposes your Honeycomb data to the public. To limit that exposure, we strongly recommend that you create a new API key with permissions that only allows Send Events. This limits the impact of the key and enables you to rotate it often.

Set up the endpoint and header in the OTLPTraceExporter.

const exporter = new OTLPTraceExporter({
  url: "https://api.honeycomb.io/v1/traces",
  headers: {
    "x-honeycomb-team": "your-api-key",
  },
})

OpenTelemetry Collector Configuration 

To receive browser telemetry, the Collector requires an enabled OTLP/HTTP receiver. The allowed_origins property is required to enable Cross-Origin Resource Sharing (CORS) from the browser to the collector. The Collector will need to be accessible by the browser. It is recommended to put an external load balancer in front of the collector, which will also need to be configured to accept requests from the browser origin.

In the example below, the configuration allows for the OpenTelemetry Collector to accept browser OpenTelemetry tracing, and is required to get data from the browser to Honeycomb.

receivers:
  otlp:
    protocols:
      http: # port 4318
        cors:
          allowed_origins:
            - "http://*.<yourdomain>.com"
            - "https://*.<yourdomain>.com"

processors:
  batch:

exporters:
  otlp:
    endpoint: "api.honeycomb.io:443"
    headers:
      "x-honeycomb-team": "YOUR_API_KEY"

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlp]

More configuration options can be found on the Collector Github Repository.

There may already be an OpenTelemetry Collector in use by other applications. If a Collector is already in use in your environment, it may be possible to make the necessary changes to the existing Collector configuration without needing to deploy something new. See more details about setting up the OpenTelemetry Collector, including deployment patterns to consider.

Custom Proxy Configuration 

An alternative to the OpenTelemetry Collector is to create your own custom proxy endpoint. Initialize the OTLPTraceExporter with the URL of the custom endpoint.

const exporter = new OTLPTraceExporter({
  url: "https://<your-custom-endpoint>",
})

Set the environment variables like the API Key and Honeycomb endpoint in the server-side code to keep it hidden from public view.

For example, to setup a proxy using a basic Express server, include the following in a post to https://api.honeycomb.io/v1/traces:

const options = {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-honeycomb-team': process.env.HONEYCOMB_API_KEY,
  },
  body: JSON.stringify(otlpJsonExportedFromFrontend),
};

The server will also need cors setup to allow connections from the browser, and will need to be able to parse json.

Example Express Proxy 

To accept POSTs to http://localhost:3000/v1/traces from the browser at http://localhost:5000 to then send on to Honeycomb, the entire code might look like this:

// frontend.js
const exporter = new OTLPTraceExporter({
  url: "http://localhost:3000/v1/traces",
})
// backend.js
import { config } from 'dotenv';
import express from 'express';
import fetch from 'node-fetch';
import cors from 'cors';

const app = express();
const port = 3000;

app.use(
  cors({
    origin: ['http://localhost:5000', 'http://127.0.0.1:5000'],
    methods: ['POST'],
    credentials: true,
  }),
);
// Allow parsing of json
app.use(express.json());

// our api relay route
app.post('/v1/traces', async (req, res) => {
  try {
    const otlpJsonExportedFromFrontend = await req.body;

    const options = {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-honeycomb-team': process.env.HONEYCOMB_API_KEY,
      },
      body: JSON.stringify(otlpJsonExportedFromFrontend),
    };

    // sending on to Honeycomb
    const response = await fetch('https://api.honeycomb.io/v1/traces', options)
      .then((response) => console.log(response))
      .catch((err) => console.error(err));

    return res.json({
      success: true,
      response,
    });
  } catch (err) {
    return res.status(500).json({
      success: false,
      message: err.message,
    });
  }
});

app.listen(port, () => console.log(`Server listening on port ${port}!`));

If your framework has server-side api routes that separate server-side code from the client-side bundle, that may be a viable option to consider for this endpoint.

Troubleshooting 

CORS Errors 

Sometimes a CORS error may occur when setting up browser telemetry.

Confirm the receiver’s setup has the correct port defined. The default port for http is 4318. If this port or endpoint is overwritten in the collector configuration file, ensure it matches the endpoint set in the application sending telemetry.

Confirm the allowed_origins list in the receivers matches the origin of the browser telemetry. If there is a load balancer in front of the Collector, it should also be configured to accept requests from the browser origin.

One way to determine whether the issue is rooted in how the application is exporting as opposed to network connectivity is to issue a curl command to the server from the browser origin.

For example, if the application was running on http://localhost:3000, and the collector was listening on port 4318 at http://otel-collector.com/v1/traces:

curl -i http://otel-collector.com/v1/traces -H "Origin: http://localhost:3000" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: X-Requested-With" -H "Content-Type: application/json" -X OPTIONS --verbose

The response from the server should include Access-Control-Allow-Credentials: true.

Did you find what you were looking for?