Observability für Hilla-Anwendungen. Teil 2: Frontend

René Wilby | 12.11.2024 Min. Lesezeit

Artikelreihe

Dies ist der zweite von zwei Artikeln, die verschiedene Aspekte zu Observability von Hilla-Anwendungen beschreiben:

Observability im Browser

Das Sammeln von Logs, Traces und Metriken stellt Entwickler:innen vor gewisse Herausforderungen, da der Browser weit entfernt und nicht unter ihrer Kontrolle ist. Dennoch ist es sehr wichtig zu erfahren, wenn Fehler oder Probleme im React-Frontend einer Hilla-Anwendung im Browser auftreten und wie die Anwender:innen die Hilla-Anwendung erleben. Erfreulicherweise existieren Ansätze, um ein React-Frontend im Browser kontinuierlich zu beobachten. Analog zum Spring Boot Backend kann auch im React-Frontend einer Hilla-Anwendung OpenTelemetry für diese Zwecke zum Einsatz kommen. Zum Zeitpunkt der Erstellung dieses Artikels befand sich in der Dokumentation von OpenTelemetry für JavaScript im Browser folgende Warnung:

Warning OpenTelemetry JavaScript Browser

Dies bedeutet, dass einige der im weiteren Verlauf vorgestellten Ansätze noch unvollständig sein können bzw. noch Änderungen erfahren können. Nichtsdestotrotz ist es interessant zu sehen, welche Möglichkeiten mit dem aktuellen Entwicklungsstand bereits heute bestehen.

Setup

Das Einbinden von OpenTelemetry in das React-Frontend einer Hilla-Anwendung beginnt mit dem Hinzufügen einiger erforderlicher Basis-Dependencies:

npm install --save @opentelemetry/api \
@opentelemetry/core \
@opentelemetry/resources \
@opentelemetry/semantic-conventions

Im Verzeichnis src/main/frontend kann nun eine Datei open-telemetry.ts angelegt werden. Diese Datei wird im weiteren Verlauf sämtliche Konfiguration für OpenTelemetry im Frontend beinhalten. Zunächst erhält die Datei folgenden Inhalt:

import { Resource } from "@opentelemetry/resources";
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";

const resourceSettings = new Resource({
  [ATTR_SERVICE_NAME]: "hilla-observability-example-frontend",
  [ATTR_SERVICE_VERSION]: "1.0.0",
});

Die Resource beschreibt, wofür später Traces und Metriken gesammelt werden sollen. Die Beschreibung der Resource erfolgt über semantische Konventionen, die OpenTelemetry als Konstanten bereitstellt.

Die Datei open-telemetry.ts muss sehr frühzeitig im React-Frontend ausgeführt werden. Dazu wird das Skript in der Datei src/main/frontend/index.html eingebunden:

<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- existing code omitted -->
    <script type="module" src="open-telemetry.ts"></script>
  </head>

  <body>
    <div id="outlet"></div>
  </body>
</html>

Tracing

Tracing ist hilfreich, um zu verstehen, wie sich Anwender:innen im React-Frontend einer Hilla-Anwendung bewegen. Traces können zum Beispiel entstehen, wenn HTTP-Requests zum Backend ausgeführt werden, wenn Anwender:innen zu Views innerhalb des Frontends navigieren oder mit Schaltflächen interagieren.

Damit Traces im Frontend verwendet werden können, muss ein passender TraceProvider konfiguriert werden. Für das React-Frontend einer Hilla-Anwendung kommt ein WebTracerProvider zum Einsatz:

npm install --save @opentelemetry/sdk-trace-web
import { Resource } from "@opentelemetry/resources";
import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";

const resourceSettings = new Resource({
  [ATTR_SERVICE_NAME]: "hilla-observability-example-frontend",
  [ATTR_SERVICE_VERSION]: "1.0.0",
});

const provider = new WebTracerProvider({ resource: resourceSettings });

In Verbindung mit dem WebTraceProvider wird ebenfalls ein passender Exporter konfiguriert, über den die erzeugten Traces exportiert werden können. Im vorliegenden Beispiel kommt hierfür ein OTLPTraceExporter zum Einsatz, der die Traces über das OpenTelemetry Protocol (OTLP) an ein passendes Backend, wie bspw. SigNoz exportiert:

npm install --save @opentelemetry/exporter-trace-otlp-http
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { Resource } from "@opentelemetry/resources";
import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";

const resourceSettings = new Resource({
  [ATTR_SERVICE_NAME]: "hilla-observability-example-frontend",
  [ATTR_SERVICE_VERSION]: "1.0.0",
});

const provider = new WebTracerProvider({ resource: resourceSettings });

const otlpTraceExporter = new OTLPTraceExporter({
  url: "http://corporate.observability.example.com:4318/v1/traces",
});

Der TraceProvider muss nun mit einem passendem SpanProcessor konfiguriert werden. Über den SpanProcessor werden alle Spans verarbeitet, die im React-Frontend entstehen. Ein typischer SpanProcessor ist der BatchSpanProcessor, der Spans auf eine konfigurierbare Art und Weise sammelt und an den Exporter übergibt:

import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { Resource } from "@opentelemetry/resources";
import {
  BatchSpanProcessor,
  WebTracerProvider,
} from "@opentelemetry/sdk-trace-web";
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";

const resourceSettings = new Resource({
  [ATTR_SERVICE_NAME]: "hilla-observability-example-frontend",
  [ATTR_SERVICE_VERSION]: "1.0.0",
});

const provider = new WebTracerProvider({ resource: resourceSettings });

const otlpTraceExporter = new OTLPTraceExporter({
  url: "http://corporate.observability.example.com:4318/v1/traces",
});

provider.addSpanProcessor(new BatchSpanProcessor(otlpTraceExporter));

Für Test- und/oder Debugging-Zwecke kann als Alternative zur Kombination von OTLPTraceExporter und BatchSpanProcessor eine Kombination aus ConsoleSpanExporter und SimpleSpanProcessor verwendet werden. In diesem Fall werden alle Spans sofort über die Browser-Konsole ausgegeben:

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

Damit Spans und Traces in einem gemeinsamen Kontext korreliert werden können, muss ein passender Context über den ZoneContextManager bereitgestellt werden:

npm install --save @opentelemetry/context-zone
import { ZoneContextManager } from "@opentelemetry/context-zone";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { Resource } from "@opentelemetry/resources";
import {
  BatchSpanProcessor,
  WebTracerProvider,
} from "@opentelemetry/sdk-trace-web";
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";

const resourceSettings = new Resource({
  [ATTR_SERVICE_NAME]: "hilla-observability-example-frontend",
  [ATTR_SERVICE_VERSION]: "1.0.0",
});

const provider = new WebTracerProvider({ resource: resourceSettings });

const otlpTraceExporter = new OTLPTraceExporter({
  url: "http://corporate.observability.example.com:4318/v1/traces",
});

provider.addSpanProcessor(new BatchSpanProcessor(otlpTraceExporter));

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

Jetzt können verschiedene Instrumentierungen hinzugefügt werden, die Traces im React-Frontend einer Hilla-Anwendung erheben können. OpenTelemetry stellt für Web-Frontend derzeit 4 Auto-Instrumentierungen zur Verfügung:

Damit diese Instrumentierungen verwendet werden können, muss vorab das Package @opentelemetry/instrumentation installiert und im OpenTelemetry-Setup eingebunden werden:

npm install --save @opentelemetry/instrumentation
import { ZoneContextManager } from "@opentelemetry/context-zone";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { Resource } from "@opentelemetry/resources";
import {
  BatchSpanProcessor,
  WebTracerProvider,
} from "@opentelemetry/sdk-trace-web";
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";

const resourceSettings = new Resource({
  [ATTR_SERVICE_NAME]: "hilla-observability-example-frontend",
  [ATTR_SERVICE_VERSION]: "1.0.0",
});

const provider = new WebTracerProvider({ resource: resourceSettings });

const otlpTraceExporter = new OTLPTraceExporter({
  url: "http://corporate.observability.example.com:4318/v1/traces",
});

provider.addSpanProcessor(new BatchSpanProcessor(otlpTraceExporter));

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

const startOpenTelemetryInstrumentation = () => {
  registerInstrumentations({
    instrumentations: [],
  });
};

startOpenTelemetryInstrumentation();

Anschließend können die Auto-Instrumentierungen hinzugefügt werden. Zunächst wird das Package @opentelemetry/instrumentation-fetch installiert und eingebunden:

npm install --save @opentelemetry/instrumentation-fetch
import { ZoneContextManager } from "@opentelemetry/context-zone";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { Resource } from "@opentelemetry/resources";
import {
  BatchSpanProcessor,
  WebTracerProvider,
} from "@opentelemetry/sdk-trace-web";
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";

const resourceSettings = new Resource({
  [ATTR_SERVICE_NAME]: "hilla-observability-example-frontend",
  [ATTR_SERVICE_VERSION]: "1.0.0",
});

const provider = new WebTracerProvider({ resource: resourceSettings });

const otlpTraceExporter = new OTLPTraceExporter({
  url: "http://corporate.observability.example.com:4318/v1/traces",
});

provider.addSpanProcessor(new BatchSpanProcessor(otlpTraceExporter));

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

const startOpenTelemetryInstrumentation = () => {
  registerInstrumentations({
    instrumentations: [
      new FetchInstrumentation({
        ignoreUrls: [/VAADIN/],
      }),
    ],
  });
};

startOpenTelemetryInstrumentation();

Mit Hilfe von instrumentation-fetch werden die HTTP-Requests, die über die fetch-API vom React-Frontend an das Spring Boot Backend der Hilla-Anwendung gesendet werden, aufgezeichnet. In der Konfiguration werden HTTP-Requests an URLs mit dem Pfad /VAADIN/ ignoriert, da es sich dabei um interne Requests des Frameworks handelt. Dies ist ein Beispiel-Trace für einen regulären HTTP-Request an einen Endpunkt namens AccommodationService:

{
  "resource": {
    "attributes": {
      "service.name": "hilla-observability-example-frontend",
      "telemetry.sdk.language": "webjs",
      "telemetry.sdk.name": "opentelemetry",
      "telemetry.sdk.version": "1.27.0",
      "service.version": "1.0.0"
    }
  },
  "instrumentationScope": {
    "name": "@opentelemetry/instrumentation-fetch",
    "version": "0.54.0"
  },
  "traceId": "44fcb174427ef9069c01c743ed0af28e",
  "name": "HTTP POST",
  "id": "2bda0ea4c46953d9",
  "kind": 2,
  "timestamp": 1730660709096000,
  "duration": 653000,
  "attributes": {
    "component": "fetch",
    "http.method": "POST",
    "http.url": "http://localhost:8080/connect/AccommodationService/list",
    "http.status_code": 200,
    "http.status_text": "",
    "http.host": "localhost:8080",
    "http.scheme": "http",
    "http.user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:131.0) Gecko/20100101 Firefox/131.0",
    "http.response_content_length": 1178
  },
  "status": {
    "code": 0
  },
  "events": [
    {
      "name": "fetchStart",
      "attributes": {},
      "time": [
        1730660709,
        97000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "domainLookupStart",
      "attributes": {},
      "time": [
        1730660709,
        97000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "domainLookupEnd",
      "attributes": {},
      "time": [
        1730660709,
        97000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "connectStart",
      "attributes": {},
      "time": [
        1730660709,
        97000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "connectEnd",
      "attributes": {},
      "time": [
        1730660709,
        97000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "requestStart",
      "attributes": {},
      "time": [
        1730660709,
        100000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "responseStart",
      "attributes": {},
      "time": [
        1730660709,
        746000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "responseEnd",
      "attributes": {},
      "time": [
        1730660709,
        746000000
      ],
      "droppedAttributesCount": 0
    }
  ],
  "links": []
}

Die nächste Auto-Instrumentierung ist @opentelemetry/instrumentation-document-load. Mit Hilfe dieser Instrumentierung kann aufgezeichnet werden, wie lange es dauert, einzelne Seiten im Frontend zu laden.

npm install --save @opentelemetry/instrumentation-document-load
import { ZoneContextManager } from "@opentelemetry/context-zone";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { Resource } from "@opentelemetry/resources";
import {
  BatchSpanProcessor,
  WebTracerProvider,
} from "@opentelemetry/sdk-trace-web";
import {
  ATTR_SERVICE_NAME,
  ATTR_SERVICE_VERSION,
} from "@opentelemetry/semantic-conventions";

const resourceSettings = new Resource({
  [ATTR_SERVICE_NAME]: "hilla-observability-example-frontend",
  [ATTR_SERVICE_VERSION]: "1.0.0",
});

const provider = new WebTracerProvider({ resource: resourceSettings });

const otlpTraceExporter = new OTLPTraceExporter({
  url: "http://corporate.observability.example.com:4318/v1/traces",
});

provider.addSpanProcessor(new BatchSpanProcessor(otlpTraceExporter));

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

const startOpenTelemetryInstrumentation = () => {
  registerInstrumentations({
    instrumentations: [
      new FetchInstrumentation({
        ignoreUrls: [/VAADIN/],
      }),
      new DocumentLoadInstrumentation(),
    ],
  });
};

startOpenTelemetryInstrumentation();

Anhand des Events documentFetch wird aufgezeichnet, wie lange es gedauert hat eine Seite im Frontend abzurufen:

{
  "resource": {
    "attributes": {
      "service.name": "hilla-observability-example-frontend",
      "telemetry.sdk.language": "webjs",
      "telemetry.sdk.name": "opentelemetry",
      "telemetry.sdk.version": "1.27.0",
      "service.version": "1.0.0"
    }
  },
  "instrumentationScope": {
    "name": "@opentelemetry/instrumentation-document-load",
    "version": "0.41.0"
  },
  "traceId": "912887b8c9901a691f106102923dcdca",
  "parentId": "4edb53d5cf305c2f",
  "name": "documentFetch",
  "id": "0b82df008473b4a6",
  "kind": 0,
  "timestamp": 1730718087008000,
  "duration": 52000,
  "attributes": {
    "http.url": "http://localhost:8080/events",
    "http.response_content_length": 8501
  },
  "status": {
    "code": 0
  },
  "events": [
    {
      "name": "fetchStart",
      "attributes": {},
      "time": [
        1730718087,
        8000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "domainLookupStart",
      "attributes": {},
      "time": [
        1730718087,
        8000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "domainLookupEnd",
      "attributes": {},
      "time": [
        1730718087,
        8000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "connectStart",
      "attributes": {},
      "time": [
        1730718087,
        8000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "connectEnd",
      "attributes": {},
      "time": [
        1730718087,
        8000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "requestStart",
      "attributes": {},
      "time": [
        1730718087,
        39000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "responseStart",
      "attributes": {},
      "time": [
        1730718087,
        60000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "responseEnd",
      "attributes": {},
      "time": [
        1730718087,
        60000000
      ],
      "droppedAttributesCount": 0
    }
  ],
  "links": []
}

Anhand des Events documentLoad wird aufgezeichnet, wie lange es gedauert hat eine Seite im Frontend vollständig zu laden:

{
  "resource": {
    "attributes": {
      "service.name": "hilla-observability-example-frontend",
      "telemetry.sdk.language": "webjs",
      "telemetry.sdk.name": "opentelemetry",
      "telemetry.sdk.version": "1.27.0",
      "service.version": "1.0.0"
    }
  },
  "instrumentationScope": {
    "name": "@opentelemetry/instrumentation-document-load",
    "version": "0.41.0"
  },
  "traceId": "912887b8c9901a691f106102923dcdca",
  "name": "documentLoad",
  "id": "4edb53d5cf305c2f",
  "kind": 0,
  "timestamp": 1730718087008000,
  "duration": 2570000,
  "attributes": {
    "http.url": "http://localhost:8080/events",
    "http.user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:131.0) Gecko/20100101 Firefox/131.0"
  },
  "status": {
    "code": 0
  },
  "events": [
    {
      "name": "fetchStart",
      "attributes": {},
      "time": [
        1730718087,
        8000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "unloadEventStart",
      "attributes": {},
      "time": [
        1730718087,
        62000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "unloadEventEnd",
      "attributes": {},
      "time": [
        1730718087,
        63000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "domInteractive",
      "attributes": {},
      "time": [
        1730718087,
        195000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "domContentLoadedEventStart",
      "attributes": {},
      "time": [
        1730718089,
        566000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "domContentLoadedEventEnd",
      "attributes": {},
      "time": [
        1730718089,
        569000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "domComplete",
      "attributes": {},
      "time": [
        1730718089,
        576000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "loadEventStart",
      "attributes": {},
      "time": [
        1730718089,
        576000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "loadEventEnd",
      "attributes": {},
      "time": [
        1730718089,
        578000000
      ],
      "droppedAttributesCount": 0
    },
    {
      "name": "firstContentfulPaint",
      "attributes": {},
      "time": [
        1730718089,
        546000000
      ],
      "droppedAttributesCount": 0
    }
  ],
  "links": []
}

Aufgrund der dynamischen Eigenschaften von Single Page Applications (SPA) ist es nicht so einfach den genauen Zeitpunkt zu bestimmen, wann eine Seite vollständig geladen wurde. Es existieren verschiedene Lifecycle-Events, wie domComplete oder firstContentfulPaint, die hier Anhaltspunkte liefern.

Standardmäßig wird mit @opentelemetry/instrumentation-document-load auch das Laden aller Ressourcen, wie zum Beispiel JavaScript- oder CSS-Dateien aufgezeichnet. Wer dies nicht wünscht, kann das zugehörige Event im Sampler des WebTraceProvider ausschließen:

import { ZoneContextManager } from '@opentelemetry/context-zone';
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { Resource } from '@opentelemetry/resources';
import {
  BatchSpanProcessor,
  SamplingDecision,
  WebTracerProvider,
} from '@opentelemetry/sdk-trace-web';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';

const resourceSettings = new Resource({
  [ATTR_SERVICE_NAME]: 'hilla-observability-example-frontend',
  [ATTR_SERVICE_VERSION]: '1.0.0',
});

const provider = new WebTracerProvider({
  resource: resourceSettings,
  sampler: {
    shouldSample: (_context, _traceId, spanName) => {
      return {
        decision: spanName == 'resourceFetch' ? SamplingDecision.NOT_RECORD : SamplingDecision.RECORD_AND_SAMPLED,
      };
    },
  },
});

const otlpTraceExporter = new OTLPTraceExporter({
  url: "http://corporate.observability.example.com:4318/v1/traces",
});

provider.addSpanProcessor(new BatchSpanProcessor(otlpTraceExporter));

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

const startOpenTelemetryInstrumentation = () => {
  registerInstrumentations({
    instrumentations: [
      new FetchInstrumentation({
        ignoreUrls: [/VAADIN/],
      }),
      new DocumentLoadInstrumentation(),
    ],
  });
};

startOpenTelemetryInstrumentation();

Die dritte Auto-Instrumentierung ist @opentelemetry/instrumentation-user-interaction. Mit Hilfe dieser Instrumentierung können Interaktionen von Anwender:innen aufgezeichnet werden. Standardmäßig werden nur Klick-Events aufgezeichnet. Dies kann bei Bedarf auf die meisten Standard-Events erweitert werden. Für das React-Frontend einer Hilla-Anwendung ist das Klick-Event in der Regel ausreichend.

npm install --save @opentelemetry/instrumentation-user-interaction
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { UserInteractionInstrumentation } from '@opentelemetry/instrumentation-user-interaction';
import { Resource } from '@opentelemetry/resources';
import {
  BatchSpanProcessor,
  SamplingDecision,
  WebTracerProvider,
} from '@opentelemetry/sdk-trace-web';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';

const resourceSettings = new Resource({
  [ATTR_SERVICE_NAME]: 'hilla-observability-example-frontend',
  [ATTR_SERVICE_VERSION]: '1.0.0',
});

const provider = new WebTracerProvider({
  resource: resourceSettings,
  sampler: {
    shouldSample: (_context, _traceId, spanName) => {
      return {
        decision: spanName == 'resourceFetch' ? SamplingDecision.NOT_RECORD : SamplingDecision.RECORD_AND_SAMPLED,
      };
    },
  },
});

const otlpTraceExporter = new OTLPTraceExporter({
  url: "http://corporate.observability.example.com:4318/v1/traces",
});

provider.addSpanProcessor(new BatchSpanProcessor(otlpTraceExporter));

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

const startOpenTelemetryInstrumentation = () => {
  registerInstrumentations({
    instrumentations: [
      new FetchInstrumentation({
        ignoreUrls: [/VAADIN/],
      }),
      new DocumentLoadInstrumentation(),
      new UserInteractionInstrumentation(),
    ],
  });
};

startOpenTelemetryInstrumentation();

Der nachfolgende Trace resultiert aus einem Klick auf das Drawer-Menü der Hilla-Anwendung:

{
  "resource": {
    "attributes": {
      "service.name": "hilla-observability-example-frontend",
      "telemetry.sdk.language": "webjs",
      "telemetry.sdk.name": "opentelemetry",
      "telemetry.sdk.version": "1.27.0",
      "service.version": "1.0.0"
    }
  },
  "instrumentationScope": {
    "name": "@opentelemetry/instrumentation-user-interaction",
    "version": "0.41.0"
  },
  "traceId": "9c234033deb0a926818226af9e82d4a7",
  "name": "click",
  "id": "6811cf1bae4b0823",
  "kind": 0,
  "timestamp": 1730721585131000,
  "duration": 0,
  "attributes": {
    "event_type": "click",
    "target_element": "VAADIN-DRAWER-TOGGLE",
    "target_xpath": "//html/body/div/vaadin-app-layout/vaadin-drawer-toggle",
    "http.url": "http://localhost:8080/events"
  },
  "status": {
    "code": 0
  },
  "events": [],
  "links": []
}

Dieser Trace resultiert aus einem Klick auf eine Zeile innerhalb der Grid-Komponente:

{
  "resource": {
    "attributes": {
      "service.name": "hilla-observability-example-frontend",
      "telemetry.sdk.language": "webjs",
      "telemetry.sdk.name": "opentelemetry",
      "telemetry.sdk.version": "1.27.0",
      "service.version": "1.0.0"
    }
  },
  "instrumentationScope": {
    "name": "@opentelemetry/instrumentation-user-interaction",
    "version": "0.41.0"
  },
  "traceId": "883427cd120292ca93e2756dbad895ea",
  "name": "click",
  "id": "5f008ad9e2cd9caf",
  "kind": 0,
  "timestamp": 1730721655936000,
  "duration": 1000,
  "attributes": {
    "event_type": "click",
    "target_element": "VAADIN-GRID-CELL-CONTENT",
    "target_xpath": "//html/body/div/vaadin-app-layout/vaadin-vertical-layout/vaadin-grid/vaadin-grid-cell-content[36]",
    "http.url": "http://localhost:8080/events"
  },
  "status": {
    "code": 0
  },
  "events": [],
  "links": []
}

Dieser Trace resultiert aus einem Klick auf einen Button, der eine Navigation zu einer anderen Seite auslöst:

{
  "resource": {
    "attributes": {
      "service.name": "hilla-observability-example-frontend",
      "telemetry.sdk.language": "webjs",
      "telemetry.sdk.name": "opentelemetry",
      "telemetry.sdk.version": "1.27.0",
      "service.version": "1.0.0"
    }
  },
  "instrumentationScope": {
    "name": "@opentelemetry/instrumentation-user-interaction",
    "version": "0.41.0"
  },
  "traceId": "275241a3b2bc496923c4de6f6b368c0c",
  "name": "Navigation: /events/new",
  "id": "058cf98572a55d73",
  "kind": 0,
  "timestamp": 1730721693994000,
  "duration": 3000,
  "attributes": {
    "event_type": "click",
    "target_element": "VAADIN-BUTTON",
    "target_xpath": "//html/body/div/vaadin-app-layout/vaadin-vertical-layout/vaadin-horizontal-layout/vaadin-button",
    "http.url": "http://localhost:8080/events"
  },
  "status": {
    "code": 0
  },
  "events": [],
  "links": []
}

In einem passenden Backend können die gesammelten Traces analysiert und ausgewertet werden. In einem Trace-Backend wie SigNoz können zum Beispiel alle Traces betrachtet werden:

SigNoz Traces Explorer

Auf Basis der Traces können in SigNoz auch Dashboards erstellt werden. Die nachfolgenden Dashboards zeigen exemplarisch die durchschnittliche Dauer eines HTTP-Requests, gruppiert nach URL und die Anzahl der Klick-Traces je URL:

SigNoz Trace Dashboards

Die erzeugten Traces im Frontend der Hilla-Anwendung haben derzeit noch keine Verbindung zu Traces im Backend der Hilla-Anwendung. Frontend- und Backend-Traces können jedoch zu Distributed Traces zusammengeführt werden. Dies ermöglicht eine deutlich umfangreichere Analyse des Request-Flows durch die Hilla-Anwendung. Zu diesem Zweck wird im OpenTelemetry-Setup im Hilla-Frontend ein passender Propagator konfiguriert:

import { ZoneContextManager } from '@opentelemetry/context-zone';
import { CompositePropagator, W3CBaggagePropagator, W3CTraceContextPropagator } from '@opentelemetry/core';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { UserInteractionInstrumentation } from '@opentelemetry/instrumentation-user-interaction';
import { Resource } from '@opentelemetry/resources';
import {
  BatchSpanProcessor,
  SamplingDecision,
  WebTracerProvider,
} from '@opentelemetry/sdk-trace-web';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';

const resourceSettings = new Resource({
  [ATTR_SERVICE_NAME]: 'hilla-observability-example-frontend',
  [ATTR_SERVICE_VERSION]: '1.0.0',
});

const provider = new WebTracerProvider({
  resource: resourceSettings,
  sampler: {
    shouldSample: (_context, _traceId, spanName) => {
      return {
        decision: spanName == 'resourceFetch' ? SamplingDecision.NOT_RECORD : SamplingDecision.RECORD_AND_SAMPLED,
      };
    },
  },
});

const otlpTraceExporter = new OTLPTraceExporter({
  url: 'http://localhost:4318/v1/traces',
});

provider.addSpanProcessor(new BatchSpanProcessor(otlpTraceExporter));

provider.register({
  contextManager: new ZoneContextManager(),
  propagator: new CompositePropagator({
    propagators: [new W3CBaggagePropagator(), new W3CTraceContextPropagator()],
  }),
});

const startOpenTelemetryInstrumentation = () => {
  registerInstrumentations({
    instrumentations: [
      new FetchInstrumentation({
        ignoreUrls: [/VAADIN/],
      }),
      new DocumentLoadInstrumentation(),
      new UserInteractionInstrumentation(),
    ],
  });
};

startOpenTelemetryInstrumentation();

Dieser Propagator sorgt dafür, dass nun alle HTTP-Requests vom Frontend an das Backend einen zusätzlichen HTTP-Header traceparent enthalten. Weiterführende Details zu diesem Mechanismus findet man zum Beispiel hier: https://www.w3.org/TR/trace-context/.

Dieser Header enthält eine Trace-ID, die im Frontend erzeugt wurde. Das Backend verwendet diese Trace-ID für die Aufzeichnung des Request-Flows und weiterer nachgelagerter Methoden-Aufrufe im Backend. Dies ermöglicht in einem Trace-Backend eine zusammenhängende Analyse von Frontend- und Backend-Spans, die zu einer Trace-ID gehören:

SigNoz Distributed Trace

Logging

Logging im Frontend einer SPA ist ein besonderes Thema. Es ist einfach Log-Nachrichten über console.log in die Browser-Konsole zu schreiben, aber dies ist nicht hilfreich für Anwendungen in Produktion, da diese Informationen nicht den Entwickler:innen zur Verfügung stehen. Die Behandlung von (un)erwarteten Fehlern stellt ebenso eine Herausforderung dar. Informationen zu Fehlern werden von Entwickler:innen für die Problemanalyse benötigt, befinden sich jedoch im Browser der Anwender:innen.

Für die Behandlung von unerwarteten Fehlern steht für das React-Frontend einer Hilla-Anwendung das Konzept der Error Boundary zur Verfügung. Kommt es zu einer unbehandelten Exception im React-Frontend, kann diese Exception von der Error Boundary aufgenommen und verarbeitet werden. Auf diesem Weg können die Fehlerinformationen gezielt verarbeitet und Anwender:innen mit hilfreichen Fehlerinformationen versorgt werden.

Eine solche Error Boundary Komponente kann bspw. über das Package react-error-boundary zu einem Hilla-Projekt hinzugefügt werden:

npm install react-error-boundary

Anschließend kann die Komponente auf möglichst hoher Ebene im React-Tree des Hilla-Frontends eingebunden werden, zum Beispiel in der Datei src/main/frontend/views/@layout.tsx:

import { CustomFallbackComponent, handleError } from 'Frontend/components/CustomErrorBoundary';
import { ErrorBoundary } from 'react-error-boundary';
...

export default function MainLayout() {
  ...
  return (
    <ErrorBoundary FallbackComponent={CustomFallbackComponent} onError={handleError} >
      <AppLayout primarySection="drawer">
        ...
      </AppLayout>
    </ErrorBoundary>
  );
}

Für die Property FallbackComponent kann eine eigene Komponente definiert werden, die im Fehlerfall gerendert wird, um Fehler-Informationen für Anwender:innen anzuzeigen. Dies kann hilfreich oder gewünscht sein, wenn man Anwender:innen keine zu technischen Fehlerdetails oder Stacktraces zeigen möchte.

import { Button } from '@vaadin/react-components';
import { FallbackProps } from 'react-error-boundary';

const CustomFallbackComponent = ({ error, resetErrorBoundary }: FallbackProps) => {
  return (
    <div className="flex flex-col h-full items-center justify-center p-l text-center box-border">
      <p>Something went wrong:</p>
      <pre style={{ color: 'red' }}>{error.message}</pre>
      <Button onClick={() => resetErrorBoundary()}>Reset</Button>
    </div>
  );
};

export { CustomFallbackComponent };

Für die Property onError kann eine Funktion hinterlegt werden, die zusätzlich im Fehlerfall ausgeführt wird. Innerhalb dieser Funktion besteht somit die Möglichkeit, die Fehlerinformationen mit Hilfe des Tracer aus dem OpenTelemetry-Setup des React-Frontends an ein geeignetes Trace-Backend zu übertragen:

import { Button } from '@vaadin/react-components';
import { FallbackProps } from 'react-error-boundary';
import { Span, SpanStatusCode, trace } from '@opentelemetry/api';

const tracer = trace.getTracer('hilla-observability-example-frontend', '1.0.0');

const handleError = (error: Error) => {
  tracer.startActiveSpan('errorBoundary', (span: Span) => {
    span.recordException(error);
    span.setStatus({
      code: SpanStatusCode.ERROR,
      message: error.message,
    });
    span.end();
  });
};

const CustomFallbackComponent = ({ error, resetErrorBoundary }: FallbackProps) => {
  return (
    <div className="flex flex-col h-full items-center justify-center p-l text-center box-border">
      <p>Something went wrong:</p>
      <pre style={{ color: 'red' }}>{error.message}</pre>
      <Button onClick={() => resetErrorBoundary()}>Reset</Button>
    </div>
  );
};

export { CustomFallbackComponent, handleError };

Innerhalb der handleError Funktion wird über den Tracer ein aktiver Span mit dem Namen errorBoundary gestartet. Innerhalb dieses Spans werden dann der eigentliche Fehler, die Fehlernachricht und ein passender Fehler-Code aufgezeichnet. In einem passenden Trace-Backend können diese Fehler-Informationen dann ausgewertet werden:

SigNoz Error Boundary

Die Spans, die über die Error Boundary erstellt werden, haben den Status ERROR. Andere Spans haben standardmäßig den Status UNSET oder OK. Auf Grundlage dieser Informationen können in einem Trace-Backend wie SigNoz Dashboards oder Alerts erstellt werden, die aufzeigen, wenn der Anteil der fehlerhaften Spans beispielsweise einen gesetzten Grenzwert überschreiten.

Metriken

Der dritte Bereich von Observability für das React-Frontend einer Hilla-Anwendung sind Metriken. Mit Hilfe von Metriken können Informationen zur Verfügbarkeit oder der Performance im Frontend gesammelt werden. Damit Metriken gesammelt werden können, muss zunächst ein MeterProvider initialisiert werden. Dieser wird über das Package @opentelemetry/sdk-metrics bereitgestellt:

npm install --save @opentelemetry/sdk-metrics

Die Datei open-telemetry.ts kann anschließend wie folgt erweitert werden:

...
import { metrics } from '@opentelemetry/api';
import { MeterProvider } from '@opentelemetry/sdk-metrics';
...

const meterProvider = new MeterProvider({
  resource: resourceSettings,
});

metrics.setGlobalMeterProvider(meterProvider);

Die erstellten Metriken können über das OpenTelemetry Protocol (OTLP) und einen passenden MetricExporter an ein passendes Metrics-Backend übertragen werden. Der OTLPMetricExporter wird über das Package @opentelemetry/exporter-metrics-otlp-http bereitgestellt:

npm install --save @opentelemetry/exporter-metrics-otlp-http

Die Datei open-telemetry.ts kann anschließend wie folgt erweitert werden:

...
import { metrics } from '@opentelemetry/api';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
...

const metricReader = new PeriodicExportingMetricReader({
  exporter: new OTLPMetricExporter({
      url: "http://corporate.observability.example.com:4318/v1/traces",
  }),
  // Export metrics every 10 seconds.
  exportIntervalMillis: 10000,
});

const meterProvider = new MeterProvider({
  resource: resourceSettings,
  readers: [metricReader],
});

metrics.setGlobalMeterProvider(meterProvider);

Mit Hilfe des OpenTelemetry JavaScript SDK können individuelle Metriken wie Counter oder Histograms erstellt werden.

Als Ergänzung oder Alternative können auch bestehende Metriken, wie die sogenannten Web Vitals in das React-Frontend einer Hilla-Anwendung eingebunden werden. Web Vitals sind eine Initiative von Google. Sie stellen verschiedene Metriken zur Verfügung, die dafür genutzt werden können, um die User Experience in einer Web-Anwendung messen zu können.

Anhand des Web Vitals LCP soll die Nutzung exemplarisch gezeigt werden. LCP steht für Largest Contentful Paint. Mit Hilfe von LCP kann die wahrgenommene Ladegeschwindigkeit einer Seite innerhalb einer Web-Anwendung gemessen werden. Dazu misst LCP den Zeitpunkt, zu dem der Hauptbereich einer Seite vollständig geladen wurde. Laut Google hat eine Web-Anwendung eine gute User Experience, wenn die Seiten einer Web-Anwendung einen LCP von 2,5 Sekunden oder weniger haben.

Um die LCP-Metrik für die Seiten im React-Frontend einer Hilla-Anwendung zu überprüfen, kann das Package web-vitals installiert werden:

npm install --save web-vitals

Anschließend kann das bestehende OpenTelemetry-Setup wie folgt erweitert werden:

...
import { metrics } from '@opentelemetry/api';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { onLCP } from 'web-vitals';
...

const metricReader = new PeriodicExportingMetricReader({
  exporter: new OTLPMetricExporter({
      url: "http://corporate.observability.example.com:4318/v1/traces",
  }),
  // Export metrics every 10 seconds.
  exportIntervalMillis: 10000,
});

const meterProvider = new MeterProvider({
  resource: resourceSettings,
  readers: [metricReader],
});

metrics.setGlobalMeterProvider(meterProvider);

const meter = metrics.getMeter('web-vitals');
const lcp = meter.createObservableGauge('lcp');
onLCP((metric) => {
  lcp.addCallback((result) => {
    result.observe(metric.value);
  });
});

In einem passenden Backend können auf Grundlage dieser Metrik zum Beispiel Dashboards erstellt werden:

SigNoz Dashboard LCP

Denkbar wäre auch einen Alert zu erstellen, der eine Benachrichtigung verschickt, wenn der LCP über einen längeren Zeitraum einen zu schlechten Wert hat.

Fazit

Observability mit Traces, Logs und Metriken für das React-Frontend einer Hilla-Anwendung zu realisieren, ist mit ein wenig Arbeit verbunden und das gezeigte Setup wird sich in Zukunft an der einen oder anderen Stelle wahrscheinlich noch ändern, da dieses Feld zum Teil noch relativ neu ist bzw. sich noch in kontinuierlicher Weiterentwicklung befindet. In Verbindung mit einem passenden Backend wie SigNoz können die gesammelten Observability-Daten sehr gut aggregiert und ausgewertet werden, um somit einen möglichst umfassenden Überblick zur Verfügbarkeit, Performance und zu Problemen im React-Frontend einer Hilla-Anwendung zu bekommen.

In Verbindung mit der kontinuierlichen Überwachung des Spring Boot Backends einer Hilla-Anwendung kann so ein sehr umfangreiches Bild der gesamten Hilla-Anwendung erreicht werden.