Analytics Engineering · SQL · Feedback Scoring
Joining CSAT Survey Scores to Page View Data in SQL
How to connect customer feedback to behavioural analytics so satisfaction scores can be analysed by journey, page and service area.
The Problem
Customer satisfaction survey data is useful on its own, but it becomes much more useful when joined back to page view and journey data.
Instead of only asking what was the average CSAT score?, you can ask which page, journey or service area was the user on when they gave that score?
The approach below joins page view data to feedback responses using the session ID and date.
LEFT JOIN {{time_aware_table("your_join_table_name_here", alias="RP", timerange="3 days")}}
ON PC.tags.x_session_id = RP.tags.x_session_id
AND PC.date = RP.date
This makes it possible to analyse satisfaction scores alongside page path, referrer, service origin, status code, session ID and comments.
The Complete Query
Here is the full SQL in context. The sections below break down the scoring and classification logic.
WITH f1 AS (
SELECT
PC.generated_at AS "Time",
PC.date AS "Date",
PC.tags.x_session_id AS "SessionID",
RP.origin AS "Service",
PC.status_code AS "Status",
PC.referrer AS "Referrer",
split(coalesce(
regexp_replace(regexp_extract(PC.referrer, '(.*)\?.*$', 1), '\/\d{1,3}$', '/[AN]/'),
regexp_replace(PC.referrer, '\/[0-9]+[A-Za-z].+[\-].+|\/[A-Za-z]+[0-9].+[\-].+|\/(?![0-9]{4}\-[0-9]{4})(?:[A-Z]|[0-9])+[\-].+\/|\/[0-9]{1,3}(?:$|\/)', '/[AN]/'),
regexp_extract(PC.referrer, '(.*)\?.*$', 1),
regexp_extract(PC.referrer, '(.*)', 1)
), '/') AS "short_referrer_array",
coalesce(
regexp_extract(PC.tags.path, '(.*)\/[0-9]{1,3}$', 1),
regexp_extract(PC.tags.path, '^(\/.*)', 1)
) AS "Page",
coalesce(
regexp_replace(regexp_extract(PC.tags.path, '(.*)\?.*$', 1), '\/\d{1,3}$', '/[AN]/'),
regexp_replace(PC.tags.path, '\/[0-9]+[A-Za-z].+[\-].+|\/[A-Za-z]+[0-9].+[\-].+|\/(?![0-9]{4}\-[0-9]{4})(?:[A-Z]|[0-9])+[\-].+\/|\/[0-9]{1,3}(?:$|\/)', '/[AN]/'),
regexp_extract(PC.tags.path, '(.*)\?.*$', 1),
regexp_extract(PC.tags.path, '(.*)', 1)
) AS "short_page",
PC.user_agent_string AS "UAString",
CASE
WHEN RP.how_do_you_feel_score = '-' THEN 0
WHEN RP.how_do_you_feel_score = '1' THEN CAST(RP.how_do_you_feel_score AS INT)
WHEN RP.how_do_you_feel_score = '2' THEN CAST(RP.how_do_you_feel_score AS INT)
WHEN RP.how_do_you_feel_score = '3' THEN CAST(RP.how_do_you_feel_score AS INT)
WHEN RP.how_do_you_feel_score = '4' THEN CAST(RP.how_do_you_feel_score AS INT)
WHEN RP.how_do_you_feel_score = '5' THEN CAST(RP.how_do_you_feel_score AS INT)
ELSE 0
END AS CScore,
CASE
WHEN RP.how_do_you_feel_score = '5' OR RP.how_do_you_feel_score = '4' THEN 'Pos'
WHEN RP.how_do_you_feel_score = '3' OR RP.how_do_you_feel_score = '2' OR RP.how_do_you_feel_score = '1' THEN 'Neg'
ELSE '0'
END AS CSRating,
IF(RP.how_easy_score = '-', 0, CAST(RP.how_easy_score AS INT)) AS NScore,
CASE
WHEN RP.how_easy_score = '5' OR RP.how_easy_score = '4' THEN 'Pos'
WHEN RP.how_easy_score = '3' OR RP.how_easy_score = '2' OR RP.how_easy_score = '1' THEN 'Neg'
ELSE '0'
END AS NSRating,
IF(RP.able_to_do = '-', 100, CAST(RP.able_to_do AS INT)) AS Ascore,
RP.why_give_score AS Comments,
regexp_replace(RP.why_give_score, '\d', '#') AS Comments_Redacted
FROM {{time_aware_table("your_table_name_here", alias="PC", timerange="3 days")}}
LEFT JOIN {{time_aware_table("your_join_table_name_here", alias="RP", timerange="3 days")}}
ON PC.tags.x_session_id = RP.tags.x_session_id
AND PC.date = RP.date
WHERE RP.origin = 'TES'
AND PC.tags.path NOT LIKE '%/assets/%'
AND PC.tags.path NOT LIKE '%/images/%'
AND PC.tags.path NOT LIKE '%/fonts/%'
GROUP BY 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16
)
SELECT
Time,
SessionID,
Date,
Service,
Page,
concat('/', try(array_join(slice(short_referrer_array, 4, cardinality(short_referrer_array) - 1), '/'))) AS "short_referrer",
short_page,
Status,
UAString,
CASE
WHEN regexp_like(Referrer, '.*print-your.*') THEN 'Printing'
WHEN regexp_like(Referrer, '.*(update-income|your-income|income-calculation).*') THEN 'Update Income'
WHEN regexp_like(Referrer, '.*current-year.*') THEN 'Current Year'
WHEN regexp_like(Referrer, '.*historic-paye.*') THEN 'Historic Years'
WHEN regexp_like(Referrer, '.*(/paye-income-tax-estimate|/detailed-income-tax-estimate|/tax-code/|/tax-codes/|/tax-free-allowance|/your-income-calc).*') THEN 'Understanding Tax'
WHEN regexp_like(Referrer, '.*tax-code-change.*') THEN 'Tax Code Change'
WHEN regexp_like(Referrer, '.*employment.*') THEN 'Employment'
WHEN regexp_like(Referrer, '.*(messages|profile).*') THEN 'User Account'
WHEN regexp_like(Referrer, '.*pension.*') THEN 'Pension'
WHEN regexp_like(Referrer, '.*underpayment.*') THEN 'Underpayment'
WHEN regexp_like(Referrer, '.*(company-benefit|car|medical-benefit|marriage|uniform).*') THEN 'Benefits Reliefs'
WHEN regexp_like(Referrer, '.*state-benefits.*') THEN 'State Benefits'
WHEN regexp_like(Referrer, '.*income-tax-history.*') THEN 'Income Tax History'
WHEN regexp_like(Referrer, '.*(income-summary|paye|income-details|check-income-tax\/income-tax).*') THEN 'Income Tax Summary / Details'
WHEN regexp_like(Referrer, '.*get-help.*') THEN 'Help Tax'
WHEN regexp_like(Referrer, '.*jrs-claims.*') THEN 'Job Retention Scheme'
WHEN regexp_like(Referrer, '.*comparison.*') THEN 'Tax Comparison'
WHEN regexp_like(Referrer, '.*p800.*') THEN 'P800'
WHEN regexp_like(Referrer, '.*telephone-number.*') THEN 'Telephone'
WHEN regexp_like(Referrer, '.*update-income.*') THEN 'Update Income'
WHEN regexp_like(Referrer, '.*signout.*') THEN 'Signout page'
WHEN regexp_like(Referrer, '.*what-do-you-want-to-do.*') THEN 'Home Dashboard'
ELSE 'Other'
END AS "Subject",
CScore,
CSRating,
NScore,
NSRating,
Ascore,
Comments_Redacted
FROM f1
WHERE regexp_like(short_page, '.*\/(signout|session-expired).*')
GROUP BY 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17
ORDER BY SessionID, Time
The Scoring Fields
The query creates three main score fields: CScore, NScore and Ascore.
CScore
Customer satisfaction score from the âhow do you feelâ question.
NScore
Ease score from the âhow easy was it?â question.
Ascore
Ability-to-complete score from the task completion response.
Handling missing scores
In this dataset, missing answers are stored as - rather than NULL. The query avoids cast errors by checking for this value before converting the score to an integer.
IF(RP.how_easy_score = '-', 0, CAST(RP.how_easy_score AS INT)) AS NScore
CAST('-' AS INT) would fail.CScore: customer satisfaction
The customer satisfaction score comes from the âhow do you feelâ survey field.
CASE
WHEN RP.how_do_you_feel_score = '-' THEN 0
WHEN RP.how_do_you_feel_score = '1' THEN CAST(RP.how_do_you_feel_score AS INT)
WHEN RP.how_do_you_feel_score = '2' THEN CAST(RP.how_do_you_feel_score AS INT)
WHEN RP.how_do_you_feel_score = '3' THEN CAST(RP.how_do_you_feel_score AS INT)
WHEN RP.how_do_you_feel_score = '4' THEN CAST(RP.how_do_you_feel_score AS INT)
WHEN RP.how_do_you_feel_score = '5' THEN CAST(RP.how_do_you_feel_score AS INT)
ELSE 0
END AS CScore
Positive and negative banding
Raw scores are useful, but for dashboards it is often easier to reduce them into positive and negative bands.
CASE
WHEN RP.how_do_you_feel_score = '5' OR RP.how_do_you_feel_score = '4' THEN 'Pos'
WHEN RP.how_do_you_feel_score = '3' OR RP.how_do_you_feel_score = '2' OR RP.how_do_you_feel_score = '1' THEN 'Neg'
ELSE '0'
END AS CSRating
| Score | Band |
|---|---|
| 5 | Pos |
| 4 | Pos |
| 3 | Neg |
| 2 | Neg |
| 1 | Neg |
| – | 0 |
This makes reporting simpler: percentage positive, percentage negative, negative feedback by service area and negative feedback by page type.
NScore: ease score
The same pattern is used for the âhow easy was it?â question.
IF(RP.how_easy_score = '-', 0, CAST(RP.how_easy_score AS INT)) AS NScore
The numeric value is then grouped into Pos, Neg or 0.
Ascore: task completion
The query also creates an ability score.
IF(RP.able_to_do = '-', 100, CAST(RP.able_to_do AS INT)) AS Ascore
This gives a task-completion indicator alongside satisfaction and ease. CSAT alone does not always explain whether the user achieved what they came to do.
Classifying the Subject from the Referrer
In this query, the subject classification is deliberately applied to the Referrer, not just the current page path.
CASE
WHEN regexp_like(Referrer, '.*print-your.*') THEN 'Printing'
WHEN regexp_like(Referrer, '.*employment.*') THEN 'Employment'
WHEN regexp_like(Referrer, '.*pension.*') THEN 'Pension'
WHEN regexp_like(Referrer, '.*underpayment.*') THEN 'Underpayment'
ELSE 'Other'
END AS "Subject"
This matters because the page where feedback is submitted may not be the page the user was trying to use. It may be a feedback page, signout page or session-expired page.
The referrer is often a better signal of the actual service area that triggered the feedback.
Why Join Scores to Page Views?
Once survey responses are joined to page view data, the dataset can answer more useful questions:
- Which service areas create the most negative feedback?
- Which referrers lead to poor satisfaction?
- Are users failing on a specific journey step?
- Are low CSAT scores linked to signout or session-expired pages?
- Are users able to complete the task even when satisfaction is low?
Conclusion
This SQL pattern turns raw feedback into structured analytics fields. It joins survey responses to behavioural data, converts text scores into numeric values, creates positive / negative bands, and classifies feedback by service area.
The result is a practical CSAT analysis dataset that can be used directly in dashboards, QA checks or journey investigations.