Skip to content

Commit

Permalink
feat: ✨ Browser attributes span processor (but also so much more) (#45)
Browse files Browse the repository at this point in the history
<!--
Thank you for contributing to the project! 💜
Please see our [OSS process
document](https://github.com/honeycombio/home/blob/main/honeycomb-oss-lifecycle-and-practices.md#)
to get an idea of how we operate.
-->

## Which problem is this PR solving?
This PR adds the `BrowserAttributesSpanProcessor` which adds rich
browser attributes to every span. Attributes like the page location and
screen height and width might change during the lifecycle of a session
so they are better captured through a Span Processor rather than on the
Resource.

- Closes #11 

## Short description of the changes
- `BrowserAttributesSpanProcessor` class that adds attributes to each
span
- `CompositeSpanProcessor` class that combines multiple span processors
together since the base SDK does not support multiple span processors
- Span processor builder that configures the `BatchSpanProcessor` with
the exporter as well as the `BrowserAttributesSpanProcessor` and an
optional user provided span processor
- Had to switch the exporter being configured through this span
processor builder since the base SDK does not support configuring an
exporter through the `traceExporter` option if a `spanProcessor` is
provided.

## How to verify that this has the expected result
- Run the test app and check for the additional browser attributes
- The data should still be exported to Honeycomb

---------

Co-authored-by: Jamie Danielson <[email protected]>
  • Loading branch information
pkanal and JamieDanielson authored Jan 31, 2024
1 parent 507ee22 commit 920d29b
Show file tree
Hide file tree
Showing 8 changed files with 345 additions and 3 deletions.
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,20 @@ The SDK adds these fields to all telemetry:
| name | status | static? | description | example |
|------|--------|---------|-------------|---------|
| user_agent.original | [stable](https://github.com/scheler/opentelemetry-specification/blob/browser-events/specification/resource/semantic_conventions/browser.md) | static | window.user_agent | `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.54 Safari/537.36` |
| browser.height | planned | per-span | `[window.innerHeight](https://developer.mozilla.org/en-US/docs/Web/API/Window/innerHeight)`, the height of the layout viewport in pixels | 287 |
| browser.height | planned | per-span | [window.innerHeight](https://developer.mozilla.org/en-US/docs/Web/API/Window/innerHeight), the height of the layout viewport in pixels | 287 |
| browser.width | planned | per-span | [window.innerWidth](https://developer.mozilla.org/en-US/docs/Web/API/Window/innerWidth), the height of the layout viewport in pixels | 1720 |
| browser.brands | stable | static | [NavigatorUAData: brands](https://developer.mozilla.org/en-US/docs/Web/API/NavigatorUAData/brands) | ["Not_A Brand 8", "Chromium 120", "Google Chrome 120"] |
| browser.platform | stable | static | [NavigatorUAData: platform](https://developer.mozilla.org/en-US/docs/Web/API/NavigatorUAData/platform) | "Windows" |
| browser.mobile | stable | static | [NavigatorUAData: mobile](https://developer.mozilla.org/en-US/docs/Web/API/NavigatorUAData/mobile) | true |
| browser.language | stable | static | [Navigator: language](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/language) | "fr-FR" |
| browser.touch_screen_enabled | stable | static | [Navigator: maxTouchPoints](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/maxTouchPoints) | true |
| `page.url` | custom | per-span | | `https://docs.honeycomb.io/getting-data-in/data-best-practices/#datasets-group-data-together?page=2` |
| `page.route` | custom | per-span | | `/getting-data-in/data-best-practices/` |
| `page.search` | custom | per-span | | `?page=2` |
| `page.hash` | custom | per-span | | `#datasets-group-data-together` |
| `page.hostname` | custom | per-span | | `docs.honeycomb.io` |
| `screen.width` | custom | static | Total available screen width in pixels. | `780` |
| `screen.height` | custom | static | Total available screen height in pixels | `1000` |
| honeycomb.distro.version | stable | static | package version | "1.2.3" |
| honeycomb.distro.runtime_version | stable | static | | "browser"
| `entry_page.url` | custom | static | | `https://docs.honeycomb.io/getting-data-in/data-best-practices/#datasets-group-data-together?page=2` |
Expand Down
13 changes: 13 additions & 0 deletions src/browser-attributes-resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Resource } from '@opentelemetry/resources';

export function configureBrowserAttributesResource(): Resource {
return new Resource({
'user_agent.original': navigator.userAgent,
//https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent#mobile_tablet_or_desktop
'browser.mobile': navigator.userAgent.includes('Mobi'),
'browser.touch_screen_enabled': navigator.maxTouchPoints > 0,
'browser.language': navigator.language,
'screen.width': window.screen.width,
'screen.height': window.screen.height,
});
}
35 changes: 35 additions & 0 deletions src/browser-attributes-span-processor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Span } from '@opentelemetry/api';
import { SpanProcessor } from '@opentelemetry/sdk-trace-base';

/**
* A {@link SpanProcessor} that adds browser specific attributes to each span
* that might change over the course of a session.
* Static attributes (e.g. User Agent) are added to the Resource.
*/
export class BrowserAttributesSpanProcessor implements SpanProcessor {
constructor() {}

onStart(span: Span) {
const { href, pathname, search, hash, hostname } = window.location;

span.setAttributes({
'browser.width': window.innerWidth,
'browser.height': window.innerHeight,
'page.hash': hash,
'page.url': href,
'page.route': pathname,
'page.hostname': hostname,
'page.search': search,
});
}

onEnd() {}

forceFlush() {
return Promise.resolve();
}

shutdown() {
return Promise.resolve();
}
}
9 changes: 7 additions & 2 deletions src/honeycomb-otel-sdk.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
import { WebSDK } from './base-otel-sdk';
import { HoneycombOptions } from './types';
import { configureHoneycombHttpJsonTraceExporter } from './http-json-trace-exporter';
import { configureHoneycombResource } from './honeycomb-resource';
import { configureEntryPageResource } from './entry-page-resource';
import { configureBrowserAttributesResource } from './browser-attributes-resource';
import { mergeResources } from './merge-resources';
import { configureDebug } from './honeycomb-debug';
import { configureSpanProcessors } from './span-processor-builder';

export class HoneycombWebSDK extends WebSDK {
constructor(options?: HoneycombOptions) {
super({
...options,
resource: mergeResources([
configureEntryPageResource(),
configureBrowserAttributesResource(),
options?.resource,
configureHoneycombResource(),
]),
traceExporter: configureHoneycombHttpJsonTraceExporter(options),
// Exporter is configured through the span processor because
// the base SDK does not allow having both a spanProcessor and a
// traceExporter configured at the same time.
spanProcessor: configureSpanProcessors(options),
});

if (options?.debug) {
Expand Down
76 changes: 76 additions & 0 deletions src/span-processor-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { HoneycombOptions } from './types';
import { BrowserAttributesSpanProcessor } from './browser-attributes-span-processor';
import {
BatchSpanProcessor,
ReadableSpan,
Span,
SpanProcessor,
} from '@opentelemetry/sdk-trace-base';
import { Context } from '@opentelemetry/api';
import { configureHoneycombHttpJsonTraceExporter } from './http-json-trace-exporter';

/**
* Builds and returns Span Processor that combines the BatchSpanProcessor, BrowserSpanProcessor,
* and optionally a user provided Span Processor.
* @param options The {@link HoneycombOptions}
* @returns a {@link CompositeSpanProcessor}
*/
export const configureSpanProcessors = (options?: HoneycombOptions) => {
const honeycombSpanProcessor = new CompositeSpanProcessor();

// We have to configure the exporter here becuase the way the base SDK is setup
// does not allow having both a `spanProcessor` and `traceExporter` configured.
honeycombSpanProcessor.addProcessor(
new BatchSpanProcessor(configureHoneycombHttpJsonTraceExporter(options)),
);

// we always want to add the browser attrs span processor
honeycombSpanProcessor.addProcessor(new BrowserAttributesSpanProcessor());

// if there is a user provided span processor, add it to the composite span processor
if (options?.spanProcessor) {
honeycombSpanProcessor.addProcessor(options?.spanProcessor);
}

return honeycombSpanProcessor;
};

/**
* A {@link SpanProcessor} that combines multiple span processors into a single
* span processor that can be passed into the SDKs `spanProcessor` option.
*/
export class CompositeSpanProcessor implements SpanProcessor {
private spanProcessors: Array<SpanProcessor> = [];

public addProcessor(processor: SpanProcessor) {
this.spanProcessors.push(processor);
}

public getSpanProcessors() {
return this.spanProcessors;
}

onStart(span: Span, parentContext: Context): void {
this.spanProcessors.forEach((processor) => {
processor.onStart(span, parentContext);
});
}

onEnd(span: ReadableSpan): void {
this.spanProcessors.forEach((processor) => {
processor.onEnd(span);
});
}

forceFlush(): Promise<void> {
return Promise.all(
this.spanProcessors.map((processor) => processor.forceFlush()),
).then(() => {});
}

shutdown(): Promise<void> {
return Promise.all(
this.spanProcessors.map((processor) => processor.forceFlush()),
).then(() => {});
}
}
21 changes: 21 additions & 0 deletions test/browser-attributes-resource.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { configureBrowserAttributesResource } from '../src/browser-attributes-resource';
import { Resource } from '@opentelemetry/resources';

test('it should return a Resource', () => {
const resource = configureBrowserAttributesResource();
expect(resource).toBeInstanceOf(Resource);
});

test('it should have location attributes', () => {
const resource = configureBrowserAttributesResource();
expect(resource.attributes).toEqual({
'browser.language': 'en-US',
'browser.mobile': false,
'browser.touch_screen_enabled': false,
'screen.height': 0,
'screen.width': 0,
// user agent will be different locally and on CI,
// we're really only testing to make sure it gets the value
'user_agent.original': navigator.userAgent,
});
});
42 changes: 42 additions & 0 deletions test/browser-attributes-span-processor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* @jest-environment-options {"url": "http://something-something.com/some-page?search_params=yes&hello=hi#the-hash"}
*/

import { BrowserAttributesSpanProcessor } from '../src/browser-attributes-span-processor';
import { ROOT_CONTEXT, SpanKind, TraceFlags } from '@opentelemetry/api';
import { BasicTracerProvider, Span } from '@opentelemetry/sdk-trace-base';

describe('BrowserAttributesSpanProcessor', () => {
const browserAttrsSpanProcessor = new BrowserAttributesSpanProcessor();

let span: Span;

beforeEach(() => {
span = new Span(
new BasicTracerProvider().getTracer('browser-attrs-testing'),
ROOT_CONTEXT,
'A Very Important Browser Span!',
{
traceId: '',
spanId: '',
traceFlags: TraceFlags.SAMPLED,
},
SpanKind.CLIENT,
);
});

test('Span processor adds extra browser attributes', () => {
browserAttrsSpanProcessor.onStart(span);

expect(span.attributes).toEqual({
'browser.width': 1024,
'browser.height': 768,
'page.hash': '#the-hash',
'page.hostname': 'something-something.com',
'page.route': '/some-page',
'page.search': '?search_params=yes&hello=hi',
'page.url':
'http://something-something.com/some-page?search_params=yes&hello=hi#the-hash',
});
});
});
141 changes: 141 additions & 0 deletions test/span-processor-builder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* @jest-environment-options {"url": "http://something-something.com/some-page?search_params=yes&hello=hi#the-hash"}
*/
import {
CompositeSpanProcessor,
configureSpanProcessors,
} from '../src/span-processor-builder';
import {
BasicTracerProvider,
BatchSpanProcessor,
Span,
SpanProcessor,
} from '@opentelemetry/sdk-trace-base';
import { ROOT_CONTEXT, SpanKind, TraceFlags } from '@opentelemetry/api';

class TestSpanProcessorOne implements SpanProcessor {
onStart(span: Span): void {
span.setAttributes({
'processor1.name': 'TestSpanProcessorOne',
});
}

onEnd(): void {}

forceFlush() {
return Promise.resolve();
}

shutdown() {
return Promise.resolve();
}
}

class TestSpanProcessorTwo implements SpanProcessor {
onStart(span: Span): void {
span.setAttributes({
'processor2.name': 'TestSpanProcessorTwo',
});
}

onEnd(): void {}

forceFlush() {
return Promise.resolve();
}

shutdown() {
return Promise.resolve();
}
}

describe('CompositeSpanProcessor', () => {
const compositeSpanProcessor = new CompositeSpanProcessor();

let span: Span;

beforeEach(() => {
span = new Span(
new BasicTracerProvider().getTracer('browser-attrs-testing'),
ROOT_CONTEXT,
'A Very Important Browser Span!',
{
traceId: '',
spanId: '',
traceFlags: TraceFlags.SAMPLED,
},
SpanKind.CLIENT,
);
});

test('Combines multiple span processors', () => {
compositeSpanProcessor.addProcessor(new TestSpanProcessorOne());
compositeSpanProcessor.addProcessor(new TestSpanProcessorTwo());

compositeSpanProcessor.onStart(span, ROOT_CONTEXT);

expect(span.attributes).toEqual({
'processor1.name': 'TestSpanProcessorOne',
'processor2.name': 'TestSpanProcessorTwo',
});
});
});

describe('configureSpanProcessors', () => {
let span: Span;

beforeEach(() => {
span = new Span(
new BasicTracerProvider().getTracer('browser-attrs-testing'),
ROOT_CONTEXT,
'A Very Important Browser Span!',
{
traceId: '',
spanId: '',
traceFlags: TraceFlags.SAMPLED,
},
SpanKind.CLIENT,
);
});
test('Configures BatchSpanProcessor & BrowserAttributesSpanProcessor by default', () => {
const honeycombSpanProcessors = configureSpanProcessors({});
expect(honeycombSpanProcessors.getSpanProcessors()).toHaveLength(2);
expect(honeycombSpanProcessors.getSpanProcessors()[0]).toBeInstanceOf(
BatchSpanProcessor,
);

honeycombSpanProcessors.onStart(span, ROOT_CONTEXT);
expect(span.attributes).toEqual({
'browser.width': 1024,
'browser.height': 768,
'page.hash': '#the-hash',
'page.hostname': 'something-something.com',
'page.route': '/some-page',
'page.search': '?search_params=yes&hello=hi',
'page.url':
'http://something-something.com/some-page?search_params=yes&hello=hi#the-hash',
});
});
test('Configures additional user provided span processor', () => {
const honeycombSpanProcessors = configureSpanProcessors({
spanProcessor: new TestSpanProcessorOne(),
});
expect(honeycombSpanProcessors.getSpanProcessors()).toHaveLength(3);
expect(honeycombSpanProcessors.getSpanProcessors()[0]).toBeInstanceOf(
BatchSpanProcessor,
);

honeycombSpanProcessors.onStart(span, ROOT_CONTEXT);
expect(span.attributes).toEqual({
'browser.width': 1024,
'browser.height': 768,
'page.hash': '#the-hash',
'page.hostname': 'something-something.com',
'page.route': '/some-page',
'page.search': '?search_params=yes&hello=hi',
'page.url':
'http://something-something.com/some-page?search_params=yes&hello=hi#the-hash',
'processor1.name': 'TestSpanProcessorOne',
});
});
});

0 comments on commit 920d29b

Please sign in to comment.