Hello Electron App

Let’s start with a basic “Hello” Electron app. I created the example application from the official Electron Quick Start project with some modifications to better demonstrate the distributed tracing functionality. The frontend implementation looks like this to start:

 

const runFoo = () => window.electron.foo(getCarrier());

 

const runBar = () => window.electron.bar(getCarrier());

 

const onLoad = async () => {

  const fooResult = await runFoo();

  const barResult = await runBar();

  console.log("COMPLETE", { fooResult, barResult });

};

 

window.addEventListener("load", onLoad);

 

I created two functions runFoo and runBar that will call equivalent foo and bar Inter-Process Communication (IPC) functions in the NodeJS backend. The functions are simple one-liners, but the reason for wrapping them in functions will become apparent later.

 

To support these calls, I then needed to update the preload.js file that will expose them to the frontend like this:

 

const { contextBridge, ipcRenderer } = require("electron");

 

contextBridge.exposeInMainWorld("electron", {

  foo: () => ipcRenderer.invoke("foo"),

  bar: () => ipcRenderer.invoke("bar"),

});

 

And, finally, in the backend, I created very simple, “Hello” handlers for them like this:

 

function registerIpcHandlers() {

  ipcMain.handle("foo", () => "Hello foo!");

  ipcMain.handle("bar", () => "Hello bar!");

}

 

The registerIpcHandlers function gets called as part of the app ready event callback as part of a conventional Electron app lifecycle. At this point the example is functionally complete, and it’s time to introduce OpenTelemetry tracing.

OpenTelemetry Configuration

Let’s start with a basic OpenTelemetry setup for the web frontend portion of the application, which would look like this:

 

import { ConsoleSpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";

import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";

import { ZoneContextManager } from "@opentelemetry/context-zone";

import { context, propagation } from "@opentelemetry/api";

 

const provider = new WebTracerProvider();

provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter()));

provider.register({ contextManager: new ZoneContextManager() });

 

const tracer = provider.getTracer("example-tracer-web");

 

Check out the package.json for this example to see what dependencies are needed to make the imports above available.

 

We’ll do a similar setup for the NodeJS backend of the application, which looks like this:

 

const { NodeSDK } = require("@opentelemetry/sdk-node");

const {

  SimpleSpanProcessor,

  ConsoleSpanExporter

} = require("@opentelemetry/sdk-trace-base");

const { trace, propagation, context} = require("@opentelemetry/api");

 

const spanProcessor = new SimpleSpanProcessor(new ConsoleSpanExporter());

const sdk = new NodeSDK({ spanProcessor });

 

const tracer = trace.getTracer("example-tracer-node");

 

In both cases, we get to the point where we have created an OpenTelemetry tracer object that we can use for tracing in the application. With that in place, it’s time to get to the interesting parts.

Frontend Tracing

Let’s start by kicking off the top-level span that will define the root of the distributed trace. To do that I use the web tracer created above and start an active span. I’m naming the span “1. onLoad”, which is certainly not a great name for a real use-case but is illustrative for this example. This looks like:

 

const runFoo = () => window.electron.foo(getCarrier());

 

const runBar = () => window.electron.bar(getCarrier());

 

const onLoad = async () => {

  await tracer.startActiveSpan("1. onLoad", async (span) => {

    const fooResult = await runFoo();

    const barResult = await runBar();

    span.end();

    console.log("COMPLETE", { fooResult, barResult });

  });

};

 

window.addEventListener("load", onLoad);

 

If you’re curious about the span creation logic shown here, check out the Create Spans with JavaScript documentation. Next, I wanted runFoo and runBar to each be spans within this trace, so the above gets modified to look like this:

 

const runFoo = async (span) => {

  const foo = await window.electron.foo(getCarrier());

  span.end();

  return foo;

}

 

const runBar = async (span) => {

  const bar = await window.electron.bar(getCarrier());

  span.end();

  return bar;

}

 

const onLoad = async () => {

  await tracer.startActiveSpan("1. onLoad", async (span) => {

    const fooResult = await tracer.startActiveSpan("2. runFoo", runFoo);

    const barResult = await tracer.startActiveSpan("3. runBar", runBar);

    span.end();

    console.log("COMPLETE", { fooResult, barResult});

  });

};

 

window.addEventListener("load", onLoad);

Backend Tracing

In the main process, the tracing is even more basic. I created a span for each handler within the IPC callbacks using the node tracer created above.

 

function registerIpcHandlers() {

  ipcMain.handle(

    "foo",

    () => tracer.startActiveSpan(

      "main:foo",

      (span) => {

        span.end();

        return "Hello foo!";

      },

    )

  );

 

  ipcMain.handle(

    "bar",

    () => tracer.startActiveSpan(

      "main:bar",

      (span) => {

        span.end();

        return "Hello bar!";

      },

    )

  );

}

 

At this point tracing is functional in both the frontend and backend, but nothing exists to connect the two together. There’s no distributed tracing yet.