npm package: @safaricom-mxl/web-sdk
Description: MXL Web SDK
Author: Francis Konde
Latest version: 0.0.11

<p align="center">
  <a href="https://gitlab.safaricom.co.ke/mxl/apm/mxl-web-sdk" target="_blank">
    <strong>MXL Web SDK</strong>
  </a>
</p>

[![Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-orange)](./LICENSE)

# MXL Web SDK

## About

The MXL Web SDK builds on top of [OpenTelemetry](https://opentelemetry.io) to capture performance data for web
applications, enabling full-stack observability of your system by connecting web and backend telemetry in a seamless
way.

Telemetry recorded through this SDK can be consumed on the MXL platform for MXL customers, but it can also be
used by those who are not MXL customers to export collected data directly to any OTel Collector, either one that
they host or is hosted by other vendors. In effect, this SDK is an alternative to using the
[OpenTelemetry JS SDK](https://github.com/open-telemetry/opentelemetry-js) directly for web apps that want to leverage
the OpenTelemetry ecosystem for observability, but also want all the advanced telemetry capture that MXL is known
for.

Currently, only Spans and Logs are supported, but other signals will be added in the future.

More documentation and examples can be found in our [Web Documentation](https://gitlab.safaricom.co.ke/mxl/apm/mxl-web-sdk)

## Quick Start

### Install the package

npm:

```sh
npm install @safaricom-mxl/web-sdk
```

yarn:

```sh
yarn add @safaricom-mxl/web-sdk
```

> [!TIP]
> For CDN installs, see [Including the SDK as a code snippet from CDN](#including-the-sdk-as-a-code-snippet-from-cdn).

### Initialize the SDK

First sign up for an MXL account by going to https://mxl.apps.ocpthikadev01.safaricom.net (see
[Using without MXL](#using-without-mxl) if you wish to skip this step).

Once you've created an MXL web application you can initialize the SDK using the appID you were given along with
the app version of your application. The following should be done as early in your app's lifecycle as possible to start
capturing telemetry:

```typescript
import { initSDK } from '@safaricom-mxl/web-sdk';

const result = initSDK({
  appID: "YOUR_MXL_APP_ID",
  appVersion: "YOUR_APP_VERSION",
});

if (!!result) {
  console.log("Successfully initialized the MXL SDK");
} else {
  console.log("Failed to initialize the MXL SDK");
}
```

At this point you should be able to rebuild your app and have MXL begin collecting telemetry. Data should start to
show up in the MXL Dashboard once the SDK reports at least 1 completed session. This can be triggered by launching
your app and then ending the session by either closing the tab/window or simply putting it in the background.

> [!NOTE]
> It may take a few minutes before the first sessions appear in your MXL dashboard.

## Adding traces

In addition to what our auto-instrumentations provide you can create your own spans for operations you'd like to track.
For the most basic usage simply start a span and end it after some operation completes:

```typescript
import { trace } from '@safaricom-mxl/web-sdk';

const span = trace.startSpan("span-name");

someAsyncOperation()
  .then(() => span.end())
  .catch(() => span.fail());
```

Only spans that are explicitly ended will be exported. Attributes and events can also be added to the span either on
start or later during its lifespan. Our API wraps that of an OpenTelemetry `Tracer` so you can follow
[these examples](https://opentelemetry.io/docs/languages/js/instrumentation/#create-spans) for more elaborate use-cases.

> [!NOTE]
> When exporting to MXL span attribute values must be strings even though the OTel interface allows for a wider
> range of types

## Adding logs

You can add basic context to sessions by emitting a breadcrumb that will be visible in the timeline for that session:

```typescript
import { session } from '@safaricom-mxl/web-sdk';

session.addBreadcrumb("something happened");
```

A full log message can also be emitted. Note that unlike emitting a breadcrumb a log is more heavy-weight and may
trigger a network request to export the data:

```typescript
import { log } from '@safaricom-mxl/web-sdk';

log.message('Loading not finished in time.', 'error', {
  attributes: {
    propertyA: 'valueA',
    propertyB: 'valueB'
  }
});
```

## Adding exceptions

The SDK automatically captures unhandled exceptions.

If there is a need to log a handled exception, this can be done manually by calling the logException method:

```typescript
import { log } from '@safaricom-mxl/web-sdk';

try {
  // some operation...
} catch (e) {
  log.logException(e as Error, {
    handled: true,
    attributes: {
      propertyA: 'valueA',
      propertyB: 'valueB'
    }
  });
}
```

## React Instrumentation

Instrumentation related to React is documented in [REACT.md](./REACT.md).

## Enriching with metadata

You can add custom properties to be included as part of the current session:

```typescript
import { session } from '@safaricom-mxl/web-sdk';

session.addProperty("my-custom-property", "some value");
```

These properties will, by default, only be included in the current session.
If you want to add permanent properties that are sent across all sessions, you can configure the lifespan option:

```typescript
session.addProperty("my-custom-property", "some value", {
  lifespan: 'permanent',
});
```

## Keeping your app version up-to-date

MXL uses the `appVersion` you provide to segment collected telemetry and allow you to view differences between
releases, as such you should make sure this value is updated whenever you release a new version of your application. If
you use your `package.json` to track versions of your app then a way to keep this up-to-date is simply to read that
value when initializing the SDK (assuming that your bundler provides a method for importing json files):

```typescript
import * as packageInfo from "../<some-path>/package.json";

initSDK({
  appID: "YOUR_MXL_APP_ID",
  appVersion: packageInfo.version,
});
```

Alternatively if your app version is generated as part of your CI/CD process, you can use our CLI tool to inject your
app version into your bundle at build time. The process is part of [Uploading sourcemaps](#upload-sourcemaps) described
below.

## Upload sourcemaps

In order to view symbolicated stack traces for exceptions in MXL you must first upload your bundle and sourcemap
files. You can do so using our CLI tool, if you haven't already you can install it using:

npm:

```sh
npm install --save-dev @safaricom-mxl/web-cli
```

yarn:

```sh
yarn add -D @safaricom-mxl/web-cli
```

You will also require a `Symbol Upload` API token. This can be found in your MXL dashboard by going
to [Settings->API](https://mxl.apps.ocpthikadev01.safaricom.net/settings/organization/api).

Then hook the CLI into your build process and point it to your path where the built JS files live in order
to perform the upload:

```sh
npx mxl-web-cli upload -a "YOUR_MXL_APP_ID" -t "YOUR_MXL_UPLOAD_API_TOKEN" -p "JS_BUILD_PATH"
```

> [!WARNING]
> The CLI must be run BEFORE the files are packaged (e.g., before creating a Docker image or deployment archive).
> The CLI injects a comment and a short function to the end of bundle files to enable symbolication, so it needs to
> happen before packaging.


Additionally, if your app version is only known at build-time you can include it in the same command to have it injected
into the bundle. If you follow this method do not also include appVersion when calling `initSDK` as that value will take
precedence:

```sh
npx mxl-web-cli upload --app-version "APP_VERSION" -a "YOUR_MXL_APP_ID" -t "YOUR_MXL_UPLOAD_API_TOKEN" -p "JS_BUILD_PATH"
```

> [!NOTE]
> We currently support symbolication of function names only when defined with the function keyword.
> For functions assigned to constants, or variables, you will still see the unsymbolicated token.
> Line and column numbers, along with file names, will always be symbolicated to the original source.

## Configuring auto-instrumentations

The SDK provides several auto-instrumentations out-of-the box, in order to change how these behave (or disable certain
ones altogether) you can pass a `defaultInstrumentationConfig` object when initializing the SDK:

```typescript
import { initSDK } from '@safaricom-mxl/web-sdk';

initSDK({
  appID: "YOUR_MXL_APP_ID",
  appVersion: "YOUR_APP_VERSION",
  defaultInstrumentationConfig: {
    omit: new Set(['@opentelemetry/instrumentation-fetch']),
  },
});
```

View the type definition for `defaultInstrumentationConfig` to see the full set of configuration options.

For more advanced customization you can also include additional instrumentations as long as they conform to the
`Instrumentation` interface:

```typescript
import { initSDK } from '@safaricom-mxl/web-sdk';

initSDK({
  appID: "YOUR_MXL_APP_ID",
  appVersion: "YOUR_APP_VERSION",
  instrumentations: [myCustomInstrumentation],
});
```

## Custom exporters

You can set up your own custom log and trace exporters and pass them in when initializing the SDK. The exporters should
be configured to point to an OTLP compatible endpoint and include any headers required for that endpoint, such as
authorization. Since these export requests will be made from a browser it is also required that the endpoint return
appropriate CORS headers in its responses:

```typescript
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';

initSDK({
  appID: "YOUR_MXL_APP_ID",
  appVersion: "YOUR_APP_VERSION",
  spanExporters: [
    new OTLPTraceExporter({
      url: 'https://example.com/endpoint/for/traces',
      headers: {
        'Authorization': 'Basic TOKEN'
      }
    }),
  ],
  logExporters: [
    new OTLPLogExporter({
      url: 'https://example.com/endpoint/for/logs',
      headers: {
        'Authorization': 'Basic TOKEN'
      }
    }),
  ],
  defaultInstrumentationConfig: {
    network: {
      ignoreUrls: ['https://example.com/endpoint/for/traces', 'https://example.com/endpoint/for/logs'],
    },
  },
});
```

> [!WARNING]
> MXL automatically creates spans for network requests, however because the OTLP export itself makes a network
> request this can produce a cycle where the export's network request creates a span which is then exported which then
> creates another span, etc.
>
> To avoid this you can configure the network instrumentation to ignore the URLs to which you are exporting as shown in
> the above snippet.

## Including the SDK as a code snippet from CDN

We recommend you include our SDK as a regular npm dependency (see [Quick Start](#quick-start)). If you prefer to include
the SDK as a code snippet from CDN, you can do so by adding the following script tag to your generated HTML file:

```html
<script src="https://cdn.jsdelivr.net/npm/@safaricom-mxl/web-sdk" crossorigin="anonymous"></script>
```

Note: we recommend you pin specific versions to avoid breaking changes. Like:

```html
<script src="https://cdn.jsdelivr.net/npm/@safaricom-mxl/web-sdk@X.X.X" crossorigin="anonymous"></script>
```

Replacing `X.X.X` with the version of the SDK you wish to include. Check available versions
on [npm](https://www.npmjs.com/package/@safaricom-mxl/web-sdk).

We recommend you add this script tag to the `<head>` of your HTML file, so that it loads before your app code. This will
expose the SDK as a global variable `MXLWebSdk` on the `window` object. This needs to be added before any script
that makes use of the sdk.

The rest of this README assumes using the SDK from an NPM installation, here are some required changes to keep in mind
as you refer to that documentation:

1) Importing the sdk from node modules is no longer valid. Instead, reference it from the global `window` object:

   ```diff
   - import { initSDK, log, page, session, trace, user } from '@safaricom-mxl/web-sdk';
   + const { initSDK, log, page, session, trace, user } = window.MXLWebSdk;
   ```

2) Our CLI tool does not support injecting an app version when loading from CDN since in that case our SDK is not
   bundled with your code, instead you will need to make sure to pass in your app version when initializing the sdk as
   in the following example:

   ```javascript
   initSDK({
     appVersion: '0.0.1',
     /*...*/
   });
   ```

### Async Loading

If you prefer to load the SDK asynchronously to avoid blocking the rendering of your page, you'll need to add the
following snippet to your HTML file. Remember to replace `X.X.X` with the version of the SDK you want to include:

```html
<script>
   !function(){window.MXLWebSdkOnReady=window.MXLWebSdkOnReady||{q:[],onReady:function(e){window.MXLWebSdkOnReady.q.push(e)}};let e=document.createElement("script");e.async=!0,e.crossOrigin="anonymous",e.src="https://cdn.jsdelivr.net/npm/@safaricom-mxl/web-sdk@X.X.X",e.onload=function(){window.MXLWebSdkOnReady.q.forEach(e=>e()),window.MXLWebSdkOnReady.q=[],window.MXLWebSdkOnReady.onReady=function(e){e()}};let n=document.getElementsByTagName("script")[0];n.parentNode.insertBefore(e,n)}();
</script>
```

By deferring the loading of the SDK, any early calls to the SDK need to be wrapped in the `onReady` method:

```javascript
window.MXLWebSdkOnReady.onReady(() => {
  window.MXLWebSdk.initSDK({
    appVersion: '0.0.1',
    /*...*/
  });
})
```

This is necessary to ensure that the SDK is fully loaded before you start using it.

> [!WARNING]
> The SDK may miss some early telemetry events emitted before the SDK is initialized if you use this method.

### Running multiple instances of the SDK

If you have multiple applications on the same page and want to run multiple instances of the SDK, you can do so by
creating an instance of the SDK for each application. Make sure that the flag `registerGlobally` is set to `false` when
initializing the SDK.

```typescript
import { initSDK } from '@safaricom-mxl/web-sdk';

const mxlSDK = initSDK({
  appID: "YOUR_MXL_APP_ID",
  appVersion: "YOUR_APP_VERSION",
  registerGlobally: false, // Prevents the SDK from registering itself globally
});
```

Since the SDK is not registered globally, you will need to use the `mxlSDK` instance to access the SDK methods
and properties.

```typescript
import { initSDK } from '@safaricom-mxl/web-sdk';

const mxlSDK = initSDK({
  appID: "YOUR_MXL_APP_ID",
  appVersion: "YOUR_APP_VERSION",
  registerGlobally: false, // Prevents the SDK from registering itself globally
});

// Use this
mxlSDK.log.message('This is a log message', 'info');

// Instead of
import { log } from '@safaricom-mxl/web-sdk';

log.message('This is a log message', 'info');
```

Some instrumentation is still being registered globally and we're actively working on making it local for each instance:

* Fetch and XHR instrumentations are registered globally, so by default the last SDK to register will override the
  previous instance's configuration and only the last instance will be able to capture network requests. If you don't
  want the last instance to be the one that captures network request you can set `omitIfAlreadyPatched` to true when
  configuring the network instrumentations to allow a different instance to control the capturing.
* Global error handler listens to all unhandled errors and rejections, all SDKs are going to report all the errors that
  are not caught.

## Network span forwarding

The SDK can be configured to inject a traceparent header on outgoing network requests. This header includes the
client-side trace ID associated with that particular network span. On the MXL backend these network spans can then
be forwarded to other telemetry providers of your choice. This gives you the ability to view client-side spans alongside
server-side ones that form part of the same overall trace to provide an end-to-end representation. More details can be
found on the [Network Span Forwarding documentation page](https://gitlab.safaricom.co.ke/mxl/apm/mxl-web-sdk).

The enabling and configuration of this feature is done through our MXL dashboard so nothing needs to be set in the
SDK to turn it on, however there are a few SDK-side configurations that prevent the feature to be aware of:

### Enabling for specific CORS requests

By default, the `traceparent` header will not be included on outgoing CORS requests. This is because the server being
hit may not be configured to allow additional headers for CORS requests (see [Access-Control-Allow-Headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Headers)).
You can specify particular URLs that you wish to forward CORS requests for and for which you know are allowed to accept
the additional header by configuring the fetch or XHR instrumentations with an allow list of strings and regexes:

```typescript
import { sdk } from '@safaricom-mxl/web-sdk';

sdk.initSDK({
  appID: "YOUR_MXL_APP_ID",
  appVersion: "YOUR_APP_VERSION",
  defaultInstrumentationConfig: {
    '@opentelemetry/instrumentation-fetch': {
      propagateTraceHeaderCorsUrls: [
        /example\.com/, // propagates for any CORS requests with URLs that match the regex
        "https://www.example.com/foo", // propagates for CORS requests to URLs that exactly match the string
      ],
    },
    '@opentelemetry/instrumentation-xml-http-request': {
      propagateTraceHeaderCorsUrls: [
        /example\.com/, // propagates for any CORS requests with URLs that match the regex
        "https://www.example.com/foo", // propagates for CORS requests to URLs that exactly match the string
      ],
    },
  },
});
```

### Conflicting configuration

Particular configurations of the SDK are incompatible with Network Span Forwarding and will cause the feature to be
turned off if set:

```typescript
import { sdk } from '@safaricom-mxl/web-sdk';

sdk.initSDK({
  appID: "YOUR_MXL_APP_ID",
  appVersion: "YOUR_APP_VERSION",

  // The following are not supported alongside Network Span Forwarding and will cause that feature to turn off:
  // 1. Setting registerGlobally to false
  registerGlobally: false,
  // 2. Providing a custom propagator
  propagator: myCustomPropagator,
  // 3. Omitting both network instrumentations
  omit: new Set(['@opentelemetry/instrumentation-fetch', '@opentelemetry/instrumentation-xml-http-request']),
});
```

### Blocking client-side

A `blockNetworkSpanForwarding` configuration flag is available to block the Network Span Forwarding feature regardless
of what has been configured server-side:

```typescript
import { sdk } from '@safaricom-mxl/web-sdk';

sdk.initSDK({
  appID: "YOUR_MXL_APP_ID",
  appVersion: "YOUR_APP_VERSION",

  blockNetworkSpanForwarding: true,
});
```

## Using without MXL

If you'd prefer not to send data to MXL you can simply omit the mxl app id when calling `initSDK`. Note that in
this case at least one custom exporter needs to be configured following the steps
from [Custom exporters](#custom-exporters) or else the SDK considers the configuration invalid.

## Browser Support

The SDK is intended to be imported as a module and transpiled by a bundler. We provide an ES2022 module version for use
in modern build pipelines. OpenTelemetry set their current minimum language feature support to ES2022. Our default ESM
SDK targets this as well to ensure compatibility. See more details in
the [OpenTelemetry docs](https://github.com/open-telemetry/opentelemetry-js#browser-support).

We also provide a CDN version that is transpiled down to ES6/ES2015 for maximum compatibility with older browsers.

## Troubleshooting

### Upgrading between major versions

Please see our [Upgrade Guide](./UPGRADING.md) for specific steps.

### Compatibility with OTel packages

The SDK is built on top of OpenTelemetry and, as such, it is possible to use it alongside other OTel libraries.
**Important: New projects should use OpenTelemetry 2.x.**
OpenTelemetry 1.x support is limited to 1.x versions of the SDK, which are deprecated.

If you wish to customize the SDK behavior by configuring custom resources, exporters, processors, or instrumentations,
you must ensure that you are using versions of the OTel packages that are compatible with our SDK:

| MXL Web SDK | Open Telemetry APIs | Core   | Instrumentations & Contrib |
|-----------------|---------------------|--------|----------------------------|
| ^2.0.0          | ^1.9.0              | ^2.0.3 | >=0.203.0 <0.300.0         |
| 1.8.2           | ^1.9.0              | 1.30.1 | 0.57.2                     |

For a full list of dependencies used by the SDK, please refer to [package.json](./package.json).

### Turning on verbose logging in the SDK

By default, the SDK will only send error level logs to the console. The log level of the SDK can be increased when
initializing as follows:

```typescript
import { initSDK, DiagLogLevel } from '@safaricom-mxl/web-sdk';

initSDK({
  appID: "YOUR_MXL_APP_ID",
  appVersion: "YOUR_APP_VERSION",
  logLevel: DiagLogLevel.ALL,
});
```

In addition, initializing the SDK with `ConsoleLogRecordExporter` and `ConsoleSpanExporter` exporters allows you to take
a more detailed look at the spans and logs that are being exported from the SDK. These can be setup as custom exporters,
in which case their output will be batched, or wrapped in custom processors to see the telemetry outputted as it gets
emitted:

```typescript
import { initSDK, DiagLogLevel } from '@safaricom-mxl/web-sdk';
import { ConsoleLogRecordExporter, SimpleLogRecordProcessor } from '@opentelemetry/sdk-logs';
import { ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-web'

initSDK({
  appID: "YOUR_MXL_APP_ID",
  appVersion: "YOUR_APP_VERSION",
  logLevel: DiagLogLevel.INFO,

  // setup as exporters to output with the same batching as when exporting to a collector endpoint
  spanExporters: [new ConsoleSpanExporter()],
  logExporters: [new ConsoleLogRecordExporter()],

  // OR, wrap exporters with simple processors to output as soon as telemetry is emitted
  spanProcessors: [new SimpleSpanProcessor(new ConsoleSpanExporter())],
  logProcessors: [new SimpleLogRecordProcessor(new ConsoleLogRecordExporter())],
});
```

### Webpack 4 Configuration

When using webpack 4, you need to add aliases to your webpack configuration to resolve dependency paths correctly:

```javascript
// webpack.config.js
const path = require('node:path');

module.exports = {
  // ... other config
  resolve: {
    alias: {
      '@opentelemetry/semantic-conventions/incubating': path.resolve(
        __dirname,
        './node_modules/@opentelemetry/semantic-conventions/build/src/index-incubating.js'
      ),
    },
  },
};
```

See the [webpack 4 integration test](../../tests/integration/platforms/webpack-4/webpack.config.js) for a complete example.

### Client-side only usage

The MXL SDK is designed for browser environments. Importing it in server-side code (e.g. Next.js Server Components,
API routes, edge middleware) will not cause runtime errors because `initSDK` detects non-browser environments and returns
`false`. SDK APIs like `session` and `trace` require browser context and will not function on the server.

For best results, import and initialize the SDK in client-only code. For Next.js setup, including App Router and Pages
Router examples and build configuration, see [NEXTJS.md](./NEXTJS.md).

## FAQ

### How is data exported from the SDK

Refer to [DATA_EXPORT.md](./DATA_EXPORT.md) for details on how data is exported from the SDK.

### How is sensitive data protected

The SDK offers a few options to help protect sensitive data, refer
to [these guidelines](https://gitlab.safaricom.co.ke/mxl/apm/mxl-web-sdk)
for more information.

## Support

If you have a feature suggestion or have spotted something that doesn't look right please open
an [issue](https://gitlab.safaricom.co.ke/mxl/apm/mxl-web-sdk/-/issues/new) for the MXL team to triage or reach out in
our [issue tracker](https://gitlab.safaricom.co.ke/mxl/apm/mxl-web-sdk/-/issues) for direct, faster assistance.

## Contributions

Please refer to our [contribution guide](../../CONTRIBUTING.md) to get started.