Skip to content

Commit ac256d1

Browse files
Feature/implement o11y (#119)
* Integrate app with OpenTelemetry
1 parent 832f41a commit ac256d1

12 files changed

+879
-1458
lines changed

package-lock.json

Lines changed: 639 additions & 1409 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
"@angular/platform-server": "^17.3.7",
3434
"@angular/router": "^17.3.7",
3535
"@angular/ssr": "^17.3.6",
36+
"@opentelemetry/api": "^1.8.0",
37+
"@opentelemetry/auto-instrumentations-web": "^0.37.0",
38+
"@opentelemetry/context-zone-peer-dep": "^1.23.0",
39+
"@opentelemetry/exporter-trace-otlp-http": "^0.49.1",
40+
"@opentelemetry/instrumentation": "^0.49.1",
41+
"@opentelemetry/sdk-trace-web": "^1.22.0",
3642
"express": "^4.19.2",
3743
"ngx-markdown": "^17.0.0",
3844
"prismjs": "^1.29.0",

src/app/app.config.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ import { MarkdownModule, MarkdownService } from 'ngx-markdown';
1414
import { HttpClient, provideHttpClient, withFetch } from '@angular/common/http';
1515
import markdownConfig from './markdown.config';
1616
import { DOCUMENT } from '@angular/common';
17+
import { AUTHORS_AVATAR_PATH_TOKEN, USE_PROCESSED_IMAGES, O11Y_CONFIG_TOKEN } from './core/config/configuration-tokens';
1718
import { HtmlInMarkdownService } from './core/services/html-in-markdown.service';
19+
import { ObservabilityConfig } from './core/model/observability.model';
20+
import * as pkg from '../../package.json';
1821
import { AssetsService } from './core/services/assets.service';
19-
import { AUTHORS_AVATAR_PATH_TOKEN, USE_PROCESSED_IMAGES } from './core/config/configuration-tokens';
2022

2123
export const appConfig: ApplicationConfig = {
2224
providers: [
@@ -36,9 +38,20 @@ export const appConfig: ApplicationConfig = {
3638
sanitize: SecurityContext.NONE,
3739
})
3840
),
41+
{
42+
provide: O11Y_CONFIG_TOKEN,
43+
useValue: <ObservabilityConfig>{
44+
apiKey: '68435ee4-f575-4dc0-968b-c43665373f5c',
45+
appName: 'bb-engineering',
46+
version: pkg.version,
47+
url: 'https://rum-collector.backbase.io/v1/traces',
48+
enabled: !isDevMode(),
49+
},
50+
},
3951
{
4052
provide: APP_INITIALIZER,
41-
useFactory: markdownConfig,
53+
multi: true,
54+
useFactory: (...deps: any) => () => markdownConfig.apply(this, deps),
4255
deps: [MarkdownService, DOCUMENT, HtmlInMarkdownService, AssetsService],
4356
},
4457
{
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { InjectionToken } from '@angular/core';
2+
import { ObservabilityConfig } from '../model/observability.model';
23

34
export const AUTHORS_AVATAR_PATH_TOKEN = new InjectionToken<string>(
45
'[CONFIG][ASSETS] Authors avatar directory path'
56
);
67

8+
export const O11Y_CONFIG_TOKEN = new InjectionToken<ObservabilityConfig>(
9+
'[CONFIG][O11Y] Observability config provider'
10+
);
11+
712
export const USE_PROCESSED_IMAGES = new InjectionToken<boolean>(
813
'[CONFIG][ASSETS] Token to control source of content assets'
914
);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export interface ObservabilityConfig {
2+
apiKey: string,
3+
appName: string,
4+
version: string,
5+
url: string,
6+
enabled: boolean,
7+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { TestBed } from '@angular/core/testing';
2+
3+
import { ObservabilityService } from './observability.service';
4+
5+
describe('ObservabilityService', () => {
6+
let service: ObservabilityService;
7+
8+
beforeEach(() => {
9+
TestBed.configureTestingModule({});
10+
service = TestBed.inject(ObservabilityService);
11+
});
12+
13+
it('should be created', () => {
14+
expect(service).toBeTruthy();
15+
});
16+
});
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { registerInstrumentations } from '@opentelemetry/instrumentation';
2+
import {
3+
WebTracerProvider,
4+
BatchSpanProcessor,
5+
TraceIdRatioBasedSampler,
6+
} from '@opentelemetry/sdk-trace-web';
7+
import { ZoneContextManager } from '@opentelemetry/context-zone-peer-dep';
8+
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
9+
import { Resource } from '@opentelemetry/resources';
10+
import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
11+
import { getWebAutoInstrumentations } from '@opentelemetry/auto-instrumentations-web';
12+
import opentelemetry, { Attributes, Span } from '@opentelemetry/api';
13+
14+
import { Inject, Injectable, PLATFORM_ID } from '@angular/core';
15+
import { O11Y_CONFIG_TOKEN } from '../config/configuration-tokens';
16+
import { ObservabilityConfig } from '../model/observability.model';
17+
import { NavigationEnd, Router } from '@angular/router';
18+
import { filter } from 'rxjs';
19+
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
20+
21+
const ANONYMOUS_USER_ID = 'uid';
22+
23+
@Injectable({
24+
providedIn: 'root'
25+
})
26+
export class ObservabilityService {
27+
28+
constructor(
29+
@Inject(O11Y_CONFIG_TOKEN) private config: ObservabilityConfig,
30+
@Inject(PLATFORM_ID) platformId: object,
31+
@Inject(DOCUMENT) private document: Document,
32+
private router: Router
33+
) {
34+
if(isPlatformBrowser(platformId) && config.enabled) {
35+
this.initiateTracking();
36+
}
37+
}
38+
39+
private initiateTracking() {
40+
const { appName, version, url, apiKey } = this.config;
41+
42+
const resource = Resource.default()?.merge(
43+
new Resource({
44+
[SEMRESATTRS_SERVICE_NAME]: appName,
45+
[SEMRESATTRS_SERVICE_VERSION]: version,
46+
sessionId: this.getSessionId(),
47+
}),
48+
);
49+
50+
const provider = new WebTracerProvider({
51+
resource,
52+
sampler: new TraceIdRatioBasedSampler(1),
53+
});
54+
55+
provider.addSpanProcessor(
56+
new BatchSpanProcessor(
57+
new OTLPTraceExporter({
58+
url: url,
59+
headers: {
60+
'Bb-App-Key': apiKey,
61+
},
62+
}),
63+
),
64+
);
65+
66+
provider.register({
67+
contextManager: new ZoneContextManager(),
68+
});
69+
70+
provider.getActiveSpanProcessor().onStart = (span: Span) => {
71+
span.setAttribute('view.name', document.title);
72+
span.setAttribute('view.path', document.location.href);
73+
};
74+
75+
registerInstrumentations({
76+
instrumentations: [
77+
getWebAutoInstrumentations({
78+
'@opentelemetry/instrumentation-document-load': {},
79+
'@opentelemetry/instrumentation-user-interaction': {},
80+
}),
81+
],
82+
});
83+
84+
this.registerPageViews();
85+
}
86+
87+
public publishEvent(payload: Attributes, event: string) {
88+
opentelemetry.trace.getTracer('@blog/observability').startActiveSpan(event, activeSpan => {
89+
activeSpan.setAttributes(payload);
90+
activeSpan.end();
91+
});
92+
}
93+
94+
private getSessionId(): string {
95+
const storage = this.document.defaultView?.window.sessionStorage;
96+
let sessionId: string = `${storage?.getItem(ANONYMOUS_USER_ID)}`;
97+
if (!this.isHex(sessionId)) {
98+
const newSessionId = window?.crypto?.getRandomValues(new Uint32Array(1))[0].toString(16);
99+
storage?.setItem(ANONYMOUS_USER_ID, newSessionId);
100+
sessionId = newSessionId;
101+
}
102+
return sessionId;
103+
}
104+
105+
private registerPageViews() {
106+
this.router.events
107+
.pipe(filter((event) => event instanceof NavigationEnd))
108+
.subscribe(() => {
109+
this.publishEvent({
110+
'user.action.event.type': 'navigation'
111+
}, 'page_view');
112+
});
113+
}
114+
115+
private isHex(value: string) {
116+
return /^[0-9a-fA-F]{8}$/.test(value);
117+
}
118+
}

src/app/core/services/posts.service.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Injectable } from '@angular/core';
2-
import { Observable, map, shareReplay, withLatestFrom } from 'rxjs';
2+
import { Observable, map, of, shareReplay, switchMap, throwError, withLatestFrom } from 'rxjs';
33
import { Post, Posts } from '../model/post.model';
44
import { HttpClient } from '@angular/common/http';
55
import { getPermalink } from '@blog/utils';
@@ -86,12 +86,15 @@ export class PostsService {
8686
);
8787
}
8888

89-
getPost(permalink: string | null): Observable<Post | undefined> {
89+
getPost(permalink: string | null): Observable<Post> {
9090
const filterByPermalink = (post: Post) =>
9191
getPermalink(post.title, post.specialCategory, post.category, post.date) === permalink;
9292
return this.getPosts(0, 1, false, (post: Post) =>
9393
filterByPermalink(post)
94-
).pipe(map(({ posts }) => posts[0]));
94+
).pipe(
95+
switchMap(({ posts }) =>
96+
posts[0] ? of(posts[0]) : throwError(() => new Error('not found')))
97+
);
9598
}
9699

97100
getCategories(): Observable<Category[]> {
Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { Component } from '@angular/core';
1+
import { Component, OnInit } from '@angular/core';
22
import { ButtonComponent } from '../../components/button/button.component';
33
import { MatButtonModule } from '@angular/material/button';
44
import { RouterLink } from '@angular/router';
5+
import { ObservabilityService } from '../../core/services/observability.service';
56

67
@Component({
78
selector: 'blog-not-found',
@@ -10,4 +11,10 @@ import { RouterLink } from '@angular/router';
1011
templateUrl: './not-found.component.html',
1112
styleUrls: ['./not-found.component.scss'],
1213
})
13-
export class NotFoundComponent {}
14+
export class NotFoundComponent implements OnInit {
15+
constructor(private observabilityService: ObservabilityService) {}
16+
17+
ngOnInit(): void {
18+
this.observabilityService.publishEvent({}, 'not-found');
19+
}
20+
}

src/app/features/post/post.component.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@ <h2>{{ post.excerpt }}</h2>
3333
</header>
3434
<div class="post__content">
3535
<aside class="post__content-table">
36-
@if (isContentReady) {
36+
@if (headers) {
3737
<blog-table-of-content
3838
class="sticky"
39-
[headers]="(headers$ | async) || []"></blog-table-of-content>
39+
[headers]="headers"></blog-table-of-content>
4040
}
4141
</aside>
4242
<article class="post__content-text center-container">

0 commit comments

Comments
 (0)