Track JS exceptions and CSP violations by GoogleAnalytics + BigQuery
If you do not track frontend errors, you lose knowledge at the exact moment customers hit friction.
A broken form, failed script, or blocked asset can silently stop a purchase, and without analytics you only see lower conversion, not the reason. That means lost customers, lost revenue, and product decisions made without the real cause.
Tracking JavaScript exceptions and CSP violations closes that gap. GA4 collects the signal, and BigQuery makes it possible to group, rank, and investigate what is breaking in production.
Init Google Analytics
I suppose you already have something like this in your code for initialiazing GA4 tracking
window.dataLayer = window.dataLayer || [];
function gtag() {
window.dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
Exceptions
This listens for uncaught JavaScript errors and rejected promises, then sends them to GA4 as exception events with message, source, and line information.
window.onerror = (message, source, lineno, colno, error) => {
if (lineno === 0 && colno === 0) {
return;
}
gtag('event', 'exception', {
description: String(message),
path: window.location.pathname,
source: source || '',
lineno,
colno,
fatal: true,
});
};
window.addEventListener('unhandledrejection', (event) => {
gtag('event', 'exception', {
description: event.reason instanceof Error ? event.reason.message : String(event.reason || 'Unknown error'),
path: window.location.pathname,
source: 'unhandledrejection',
fatal: false,
});
});
CSP Violations
This listens for browser securitypolicyviolation events and sends the blocked URL, directive, and file location to GA4 as csp_violation.
document.addEventListener('securitypolicyviolation', (event) => {
gtag('event', 'csp_violation', {
document_uri: event.documentURI || '',
blocked_uri: event.blockedURI || '',
violated_directive: event.violatedDirective || '',
effective_directive: event.effectiveDirective || '',
source_file: event.sourceFile || '',
line_number: event.lineNumber || 0,
column_number: event.columnNumber || 0,
disposition: event.disposition || '',
});
});
Connect GA4 To BigQuery
For the GA4 -> BigQuery connector/export flow:
- In GA4, open
Admin. - Under
Product links, openBigQuery Links. - Create a link to your Google Cloud project.
- Choose the correct region and property data stream.
- Enable daily export. Enable streaming export too if you want fresher
intradaytables. - After the link is active, GA4 starts writing base tables like
events_YYYYMMDDand near-real-time tables likeevents_intraday_YYYYMMDD.
Getting errors info
Replace placeholders before running queries:
your_projectyour_dataset2026040120260430
Use the base events_YYYYMMDD tables for stable daily reporting.
Use events_intraday_YYYYMMDD together with the base tables when you want near-real-time numbers for the latest day.
Base + near-real-time CTE:
WITH params AS (
SELECT
'20260401' AS start_date,
'20260430' AS end_date,
'intraday_20260430' AS intraday_suffix
),
base_events AS (
SELECT
user_pseudo_id,
event_timestamp,
event_name,
LOWER((SELECT ep.value.string_value FROM UNNEST(event_params) ep WHERE ep.key = 'page_location')) AS page_location
FROM `your_project.your_dataset.events_*`
CROSS JOIN params
WHERE (
_TABLE_SUFFIX BETWEEN start_date AND end_date
OR _TABLE_SUFFIX = intraday_suffix
)
AND event_name IN ('exception', 'csp_violation')
)
Sample response:
| user_pseudo_id | event_timestamp | event_name | page_location |
|---|---|---|---|
187654321.1714450001 |
1714485123456789 |
exception |
https://example.com/checkout |
187654321.1714450001 |
1714485127890123 |
csp_violation |
null |
287654321.1714450099 |
1714485999123456 |
exception |
https://example.com/catalog |
Exception Analysis In BigQuery
If you send normalized exception events from JavaScript, you can group them in BigQuery and see which pages are actually failing.
WITH params AS (
SELECT
'20260401' AS start_date,
'20260430' AS end_date,
'intraday_20260430' AS intraday_suffix
),
exception_events AS (
SELECT
TIMESTAMP_MICROS(event_timestamp) AS event_ts,
LOWER((SELECT ep.value.string_value FROM UNNEST(event_params) ep WHERE ep.key = 'page_location')) AS page_location,
(SELECT ep.value.string_value FROM UNNEST(event_params) ep WHERE ep.key = 'description') AS description,
(SELECT ep.value.string_value FROM UNNEST(event_params) ep WHERE ep.key = 'source') AS source,
COALESCE(
(SELECT ep.value.int_value FROM UNNEST(event_params) ep WHERE ep.key = 'lineno'),
SAFE_CAST((SELECT ep.value.string_value FROM UNNEST(event_params) ep WHERE ep.key = 'lineno') AS INT64)
) AS lineno,
COALESCE(
(SELECT ep.value.int_value FROM UNNEST(event_params) ep WHERE ep.key = 'colno'),
SAFE_CAST((SELECT ep.value.string_value FROM UNNEST(event_params) ep WHERE ep.key = 'colno') AS INT64)
) AS colno
FROM `your_project.your_dataset.events_*`
CROSS JOIN params
WHERE (
_TABLE_SUFFIX BETWEEN start_date AND end_date
OR _TABLE_SUFFIX = intraday_suffix
)
AND event_name = 'exception'
),
normalized AS (
SELECT
event_ts,
COALESCE(description, '') AS description,
COALESCE(source, '') AS source,
COALESCE(lineno, 0) AS line,
COALESCE(colno, 0) AS col,
REGEXP_EXTRACT(page_location, r'^https?://([^/]+)') AS host,
COALESCE(REGEXP_EXTRACT(page_location, r'^https?://[^/]+(/[^?#]*)'), '/') AS path
FROM exception_events
WHERE page_location IS NOT NULL
)
SELECT
COALESCE(REGEXP_EXTRACT(description, r'([A-Za-z]+Error)'), '') AS error_code,
description,
source,
line,
col,
host,
path,
COUNT(*) AS count,
FORMAT_TIMESTAMP('%Y-%m-%d %H:%M:%S UTC', MAX(event_ts), 'UTC') AS last_seen
FROM normalized
GROUP BY error_code, description, source, line, col, host, path
ORDER BY count DESC, last_seen DESC, host ASC, path ASC;
Sample response:
| error_code | description | source | line | col | host | path | count | last_seen |
|---|---|---|---|---|---|---|---|---|
TypeError |
Cannot read properties of null (reading 'value') |
https://example.com/assets/app.js |
182 |
17 |
example.com |
/checkout |
24 |
2026-04-30 14:52:11 UTC |
SyntaxError |
Unexpected token '<' |
unhandledrejection |
0 |
0 |
example.com |
/catalog |
7 |
2026-04-30 13:08:44 UTC |
CSP Violation Analysis In BigQuery
Use a separate query for csp_violation so policy issues do not get mixed with runtime exceptions.
WITH params AS (
SELECT
'20260401' AS start_date,
'20260430' AS end_date,
'intraday_20260430' AS intraday_suffix
),
csp_events AS (
SELECT
TIMESTAMP_MICROS(event_timestamp) AS event_ts,
LOWER((SELECT ep.value.string_value FROM UNNEST(event_params) ep WHERE ep.key = 'document_uri')) AS document_uri,
(SELECT ep.value.string_value FROM UNNEST(event_params) ep WHERE ep.key = 'blocked_uri') AS blocked_uri,
(SELECT ep.value.string_value FROM UNNEST(event_params) ep WHERE ep.key = 'violated_directive') AS violated_directive,
(SELECT ep.value.string_value FROM UNNEST(event_params) ep WHERE ep.key = 'effective_directive') AS effective_directive,
(SELECT ep.value.string_value FROM UNNEST(event_params) ep WHERE ep.key = 'source_file') AS source_file,
COALESCE(
(SELECT ep.value.int_value FROM UNNEST(event_params) ep WHERE ep.key = 'line_number'),
SAFE_CAST((SELECT ep.value.string_value FROM UNNEST(event_params) ep WHERE ep.key = 'line_number') AS INT64)
) AS line_number,
COALESCE(
(SELECT ep.value.int_value FROM UNNEST(event_params) ep WHERE ep.key = 'column_number'),
SAFE_CAST((SELECT ep.value.string_value FROM UNNEST(event_params) ep WHERE ep.key = 'column_number') AS INT64)
) AS column_number,
(SELECT ep.value.string_value FROM UNNEST(event_params) ep WHERE ep.key = 'disposition') AS disposition
FROM `your_project.your_dataset.events_*`
CROSS JOIN params
WHERE (
_TABLE_SUFFIX BETWEEN start_date AND end_date
OR _TABLE_SUFFIX = intraday_suffix
)
AND event_name = 'csp_violation'
),
normalized AS (
SELECT
event_ts,
COALESCE(blocked_uri, '') AS blocked_uri,
COALESCE(violated_directive, '') AS violated_directive,
COALESCE(effective_directive, '') AS effective_directive,
COALESCE(source_file, '') AS source_file,
COALESCE(line_number, 0) AS line_number,
COALESCE(column_number, 0) AS column_number,
COALESCE(disposition, '') AS disposition,
REGEXP_EXTRACT(document_uri, r'^https?://([^/]+)') AS host,
COALESCE(REGEXP_EXTRACT(document_uri, r'^https?://[^/]+(/[^?#]*)'), '/') AS path
FROM csp_events
WHERE document_uri IS NOT NULL
)
SELECT
violated_directive,
effective_directive,
blocked_uri,
source_file,
line_number,
column_number,
disposition,
host,
path,
COUNT(*) AS count,
FORMAT_TIMESTAMP('%Y-%m-%d %H:%M:%S UTC', MAX(event_ts), 'UTC') AS last_seen
FROM normalized
GROUP BY violated_directive, effective_directive, blocked_uri, source_file, line_number, column_number, disposition, host, path
ORDER BY count DESC, last_seen DESC, host ASC, path ASC;
Sample response:
| violated_directive | effective_directive | blocked_uri | source_file | line_number | column_number | disposition | host | path | count | last_seen |
|---|---|---|---|---|---|---|---|---|---|---|
script-src-elem |
script-src-elem |
https://cdn.example.net/widget.js |
https://example.com/checkout |
0 |
0 |
enforce |
example.com |
/checkout |
15 |
2026-04-30 14:55:02 UTC |
img-src |
img-src |
data |
https://example.com/catalog |
0 |
0 |
report |
example.com |
/catalog |
4 |
2026-04-30 11:21:09 UTC |