Skip to content

Commit 155efb9

Browse files
committed
PD-5449
1 parent 57be9ed commit 155efb9

5 files changed

Lines changed: 337 additions & 15 deletions

File tree

src/app/core/error-handler/error-handler.service.ts

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { HttpErrorResponse } from '@angular/common/http'
2-
import { Injectable } from '@angular/core'
2+
import { Inject, Injectable } from '@angular/core'
33
import { of, throwError } from 'rxjs'
44
import { catchError, switchMap, take } from 'rxjs/operators'
55
import { PlatformInfoService } from 'src/app/cdk/platform-info'
66
import { SnackbarService } from 'src/app/cdk/snackbar/snackbar.service'
7+
import { WINDOW } from 'src/app/cdk/window'
78
import { ErrorReport } from 'src/app/types'
89
import { ERROR_REPORT } from 'src/app/errors'
910
import { CookieService } from 'ngx-cookie-service'
1011
import { RumJourneyEventService } from 'src/app/rum/service/customEvent.service'
1112
import { AppEventName } from 'src/app/rum/app-event-names'
13+
import { httpErrorEventAttrs } from 'src/app/rum/http-error-event-attrs'
1214

1315
@Injectable({
1416
providedIn: 'root',
@@ -19,7 +21,8 @@ export class ErrorHandlerService {
1921
private _cookie: CookieService,
2022
private _platform: PlatformInfoService,
2123
private _snackBar: SnackbarService,
22-
private _observability: RumJourneyEventService
24+
private _observability: RumJourneyEventService,
25+
@Inject(WINDOW) private _window: Window
2326
) {
2427
this.checkBrowser()
2528
}
@@ -33,7 +36,7 @@ export class ErrorHandlerService {
3336
const platformDetails = this.browserSupport + this.checkCSRF()
3437

3538
if (error instanceof HttpErrorResponse) {
36-
const split = error.url.split('/')
39+
const split = (error.url || '').split('/')
3740
const url = split[split.length - 1]
3841

3942
return throwError({
@@ -62,14 +65,21 @@ export class ErrorHandlerService {
6265
if (httpLike) {
6366
const http = processedError as HttpErrorResponse
6467
try {
65-
this._observability.recordSimpleEvent(AppEventName.HttpError, {
66-
status: http.status,
67-
statusText: http.statusText,
68-
url: http.url,
69-
name: http.name,
70-
browserSupport: this.browserSupport,
71-
csrf: this.checkCSRF(),
72-
})
68+
this._observability.recordSimpleEvent(
69+
AppEventName.HttpError,
70+
httpErrorEventAttrs(http, {
71+
browserSupport: this.browserSupport,
72+
csrf: this.checkCSRF(),
73+
xsrfCookiePresent: this.hasXsrfCookie(),
74+
authXsrfCookiePresent: this.hasAuthXsrfCookie(),
75+
csrfCookieState: this.csrfCookieState(),
76+
currentOrigin: this._window?.location?.origin,
77+
currentHost: this._window?.location?.host,
78+
currentPath: this._window?.location?.pathname,
79+
referrerHost: this.referrerHost(),
80+
isOnline: this._window?.navigator?.onLine,
81+
})
82+
)
7383
} catch (_) {}
7484
console.error(
7585
`
@@ -120,10 +130,41 @@ stack: "${processedError.stack}"
120130
}
121131

122132
private checkCSRF() {
123-
if (!this._cookie.get('XSRF-TOKEN')) {
124-
return 'no-XSRF'
125-
} else {
126-
return ''
133+
return this.csrfCookieState() === 'none' ? 'no-XSRF' : ''
134+
}
135+
136+
private hasXsrfCookie(): boolean {
137+
return !!this._cookie.get('XSRF-TOKEN')
138+
}
139+
140+
private hasAuthXsrfCookie(): boolean {
141+
return !!this._cookie.get('AUTH-XSRF-TOKEN')
142+
}
143+
144+
private csrfCookieState(): 'both' | 'xsrf_only' | 'auth_only' | 'none' {
145+
const hasXsrf = this.hasXsrfCookie()
146+
const hasAuthXsrf = this.hasAuthXsrfCookie()
147+
if (hasXsrf && hasAuthXsrf) {
148+
return 'both'
149+
}
150+
if (hasXsrf) {
151+
return 'xsrf_only'
152+
}
153+
if (hasAuthXsrf) {
154+
return 'auth_only'
155+
}
156+
return 'none'
157+
}
158+
159+
private referrerHost(): string | undefined {
160+
const referrer = this._window?.document?.referrer
161+
if (!referrer) {
162+
return undefined
163+
}
164+
try {
165+
return new URL(referrer).host
166+
} catch {
167+
return undefined
127168
}
128169
}
129170
}

src/app/rum/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ Flow charts live in each flow-specific doc:
8282
- Keep dashboards scoped to prefixed journey fields:
8383
- `journeyContext_*`
8484
- `eventAttribute_*`
85+
- Global HTTP errors (`actionName = 'http_error'`) now include request/page context:
86+
- `requestPath`, `requestHost`, `requestOriginType`, `currentPath`, `currentHost`, `referrerHost`, `isOnline`, `statusZeroCause`, `errorBody`, `errorBodyType`
87+
- XSRF cookie diagnostics: `xsrfCookiePresent`, `authXsrfCookiePresent`, `csrfCookieState`
88+
- Example for high-volume status 0 triage:
89+
- `FROM PageAction SELECT count(*) WHERE actionName = 'http_error' AND status = 0 FACET statusZeroCause, requestPath, currentPath`
8590

8691
## Troubleshooting / gotchas
8792

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { HttpErrorResponse } from '@angular/common/http'
2+
import { httpErrorEventAttrs } from './http-error-event-attrs'
3+
4+
describe('httpErrorEventAttrs', () => {
5+
it('adds request and page context for status 0', () => {
6+
const err = new HttpErrorResponse({
7+
status: 0,
8+
statusText: 'Unknown Error',
9+
url: '/oauth/authorize',
10+
error: { isTrusted: true, type: 'error' },
11+
})
12+
13+
const attrs = httpErrorEventAttrs(err, {
14+
browserSupport: '',
15+
csrf: '',
16+
xsrfCookiePresent: true,
17+
authXsrfCookiePresent: false,
18+
csrfCookieState: 'xsrf_only',
19+
currentOrigin: 'https://orcid.org',
20+
currentHost: 'orcid.org',
21+
currentPath: '/signin',
22+
referrerHost: 'accounts.google.com',
23+
isOnline: true,
24+
})
25+
26+
expect(attrs.requestPath).toBe('/oauth/authorize')
27+
expect(attrs.requestHost).toBe('orcid.org')
28+
expect(attrs.requestOriginType).toBe('same_origin')
29+
expect(attrs.currentPath).toBe('/signin')
30+
expect(attrs.referrerHost).toBe('accounts.google.com')
31+
expect(attrs.xsrfCookiePresent).toBeTrue()
32+
expect(attrs.authXsrfCookiePresent).toBeFalse()
33+
expect(attrs.csrfCookieState).toBe('xsrf_only')
34+
expect(attrs.statusZeroCause).toBe('network_or_cors')
35+
expect(attrs.errorBody).toBe('{"isTrusted":true,"type":"error"}')
36+
expect(attrs.errorBodyType).toBe('object:Object')
37+
})
38+
39+
it('classifies offline status 0 correctly', () => {
40+
const err = new HttpErrorResponse({
41+
status: 0,
42+
statusText: 'Unknown Error',
43+
url: 'https://api.orcid.org/oauth/token',
44+
error: { isTrusted: true },
45+
})
46+
47+
const attrs = httpErrorEventAttrs(err, {
48+
browserSupport: 'unsupported',
49+
csrf: 'no-XSRF',
50+
xsrfCookiePresent: false,
51+
authXsrfCookiePresent: false,
52+
csrfCookieState: 'none',
53+
currentOrigin: 'https://orcid.org',
54+
isOnline: false,
55+
})
56+
57+
expect(attrs.requestOriginType).toBe('cross_origin')
58+
expect(attrs.csrfCookieState).toBe('none')
59+
expect(attrs.statusZeroCause).toBe('offline')
60+
expect(attrs.errorBody).toBe('{"isTrusted":true}')
61+
})
62+
63+
it('does not set statusZeroCause for non-zero statuses', () => {
64+
const err = new HttpErrorResponse({
65+
status: 500,
66+
statusText: 'Server Error',
67+
url: '/api/account',
68+
error: 'boom',
69+
})
70+
71+
const attrs = httpErrorEventAttrs(err, {
72+
browserSupport: '',
73+
csrf: '',
74+
currentOrigin: 'https://orcid.org',
75+
isOnline: true,
76+
})
77+
78+
expect(attrs.errorBodyType).toBe('string')
79+
expect(attrs.errorBody).toBe('boom')
80+
expect(attrs.statusZeroCause).toBeUndefined()
81+
})
82+
})
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { HttpErrorResponse } from '@angular/common/http'
2+
3+
type HttpErrorContext = {
4+
browserSupport: string
5+
csrf: string
6+
xsrfCookiePresent?: boolean
7+
authXsrfCookiePresent?: boolean
8+
csrfCookieState?: 'both' | 'xsrf_only' | 'auth_only' | 'none'
9+
currentOrigin?: string
10+
currentHost?: string
11+
currentPath?: string
12+
referrerHost?: string
13+
isOnline?: boolean
14+
}
15+
16+
type StatusZeroCause = 'offline' | 'aborted' | 'network_or_cors' | 'unknown'
17+
const MAX_ERROR_BODY_CHARS = 1000
18+
19+
export function httpErrorEventAttrs(
20+
error: HttpErrorResponse,
21+
context: HttpErrorContext
22+
): Record<string, unknown> {
23+
const attrs: Record<string, unknown> = {
24+
status: error.status,
25+
statusText: error.statusText,
26+
url: error.url,
27+
name: error.name,
28+
browserSupport: context.browserSupport,
29+
csrf: context.csrf,
30+
xsrfCookiePresent: context.xsrfCookiePresent,
31+
authXsrfCookiePresent: context.authXsrfCookiePresent,
32+
csrfCookieState: context.csrfCookieState,
33+
requestPath: requestPath(error.url, context.currentOrigin),
34+
requestHost: requestHost(error.url, context.currentOrigin),
35+
requestOriginType: requestOriginType(error.url, context.currentOrigin),
36+
currentPath: context.currentPath,
37+
currentHost: context.currentHost,
38+
referrerHost: context.referrerHost,
39+
isOnline: context.isOnline,
40+
errorBody: errorBody(error.error),
41+
errorBodyType: errorBodyType(error.error),
42+
}
43+
44+
const zeroCause = statusZeroCause(error, context.isOnline)
45+
if (zeroCause) {
46+
attrs.statusZeroCause = zeroCause
47+
}
48+
49+
return attrs
50+
}
51+
52+
function statusZeroCause(
53+
error: HttpErrorResponse,
54+
isOnline: boolean | undefined
55+
): StatusZeroCause | undefined {
56+
if (error.status !== 0) {
57+
return undefined
58+
}
59+
60+
if (isOnline === false) {
61+
return 'offline'
62+
}
63+
64+
const body = error.error as any
65+
if (body && typeof body === 'object') {
66+
if (typeof body.type === 'string' && body.type.toLowerCase() === 'abort') {
67+
return 'aborted'
68+
}
69+
if ('isTrusted' in body) {
70+
return 'network_or_cors'
71+
}
72+
}
73+
74+
if (/abort/i.test(error.message || '')) {
75+
return 'aborted'
76+
}
77+
78+
if (isOnline === true) {
79+
return 'network_or_cors'
80+
}
81+
return 'unknown'
82+
}
83+
84+
function requestPath(
85+
url: string | null,
86+
currentOrigin: string | undefined
87+
): string | undefined {
88+
const parsed = parseUrl(url, currentOrigin)
89+
return parsed?.pathname
90+
}
91+
92+
function requestHost(
93+
url: string | null,
94+
currentOrigin: string | undefined
95+
): string | undefined {
96+
const parsed = parseUrl(url, currentOrigin)
97+
return parsed?.host
98+
}
99+
100+
function requestOriginType(
101+
url: string | null,
102+
currentOrigin: string | undefined
103+
): 'same_origin' | 'cross_origin' | 'invalid_or_missing' {
104+
const parsed = parseUrl(url, currentOrigin)
105+
if (!parsed) {
106+
return 'invalid_or_missing'
107+
}
108+
if (!currentOrigin) {
109+
return 'invalid_or_missing'
110+
}
111+
return parsed.origin === currentOrigin ? 'same_origin' : 'cross_origin'
112+
}
113+
114+
function parseUrl(
115+
url: string | null,
116+
currentOrigin: string | undefined
117+
): URL | undefined {
118+
if (!url) {
119+
return undefined
120+
}
121+
try {
122+
if (currentOrigin) {
123+
return new URL(url, currentOrigin)
124+
}
125+
return new URL(url)
126+
} catch {
127+
return undefined
128+
}
129+
}
130+
131+
function errorBodyType(value: unknown): string {
132+
if (value == null || value === '') {
133+
return 'none'
134+
}
135+
if (typeof value === 'string') {
136+
return 'string'
137+
}
138+
if (value instanceof ProgressEvent) {
139+
return 'progress_event'
140+
}
141+
if (Array.isArray(value)) {
142+
return 'array'
143+
}
144+
if (typeof value === 'object') {
145+
const ctor = (value as object).constructor?.name
146+
return ctor ? `object:${ctor}` : 'object'
147+
}
148+
return typeof value
149+
}
150+
151+
function errorBody(value: unknown): string | undefined {
152+
if (value == null || value === '') {
153+
return undefined
154+
}
155+
if (typeof value === 'string') {
156+
return truncate(value, MAX_ERROR_BODY_CHARS)
157+
}
158+
if (value instanceof ProgressEvent) {
159+
return truncate(
160+
JSON.stringify({
161+
type: value.type,
162+
isTrusted: value.isTrusted,
163+
}),
164+
MAX_ERROR_BODY_CHARS
165+
)
166+
}
167+
if (typeof value === 'object') {
168+
try {
169+
return truncate(JSON.stringify(value), MAX_ERROR_BODY_CHARS)
170+
} catch {
171+
return undefined
172+
}
173+
}
174+
return truncate(String(value), MAX_ERROR_BODY_CHARS)
175+
}
176+
177+
function truncate(value: string, maxChars: number): string {
178+
if (value.length <= maxChars) {
179+
return value
180+
}
181+
return `${value.slice(0, maxChars)}...[truncated]`
182+
}

0 commit comments

Comments
 (0)