feat(client): report the browser and both versions in browser_info
Build / Build-and-ng-test (pull_request) Successful in 5m15s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m12s
Build / Build-and-test-development (pull_request) Successful in 25m12s

Adds dc_version, adapter_version, browser, browser_version, platform and the
raw user_agent to the browser_info row, so a support ticket can be read off
the log and a hook can branch on the browser without parsing the user agent
itself. The summary comes from a new parse-user-agent util, following the
existing shared/utils convention.

Also adds a Cypress spec asserting the table reaches the adapter interface,
which is the only place the payload is observable end to end.
This commit is contained in:
dc committed 2026-09-24 19:31:13 +00:00
1 parent 7abd260f98
commit a770f3695c
3 files changed
+117 -2

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
// Marks this file as an ES module so its top-level consts don't collide with
// other spec files under the TS type-checker.
export {}
//
// The client sends a single-row `browser_info` input table with every service
// call, describing where the request came from - the URL of the Data
// Controller page itself, so an embedded report (and any parameters its author
// added to the embed URL) can be told apart. Hook scripts read it from WORK.
//
// Asserted here on the adapter interface, which is the only place the payload
// is observable end to end.
const hostUrl = Cypress.env('hosturl')
context('browser_info input table: ', function () {
this.beforeAll(() => {
cy.visit(hostUrl, { timeout: 60000 })
// The evaluation agreement is per-origin localStorage state, so a fresh
// profile (a new origin, or CI) lands on it before anything else.
cy.get('body').then(($body) => {
if ($body.find('#TCS input[type="checkbox"]').length) {
cy.get('#TCS input[type="checkbox"]').check({ force: true })
cy.wait(4000)
}
})
})
it('1 | is sent with the service calls the app makes', () => {
const payloads: string[] = []
cy.intercept('**/stp/execute/**', (req) => {
if (typeof req.body === 'string') payloads.push(req.body)
})
// Opening the editor directly makes the app call editors/getdata.
cy.visit(`${hostUrl}/#/editor/DC_JSLIB.MPE_X_TEST`, { timeout: 60000 })
cy.get('#hotTable', { timeout: 60000 }).should('exist')
cy.wait(8000)
cy.then(() => {
const joined = payloads.join('\n')
expect(
payloads.length,
`service calls captured (${payloads.length})`
).to.be.greaterThan(0)
expect(joined, 'browser_info table in the payload').to.contain(
'browser_info'
)
// TIMEZONE is a column of browser_info and of no other input table, so
// this shows the row itself arrived, not just the table name.
expect(joined, 'browser_info row content in the payload').to.contain(
'timezone'
)
})
})
})
+10 -2
View File
@@ -21,6 +21,7 @@ import { SasViyaService } from './sas-viya.service'
import { ViyaApiFolder } from '../viya-api-explorer/models/viya-api-folder.model'
import { ViyaApiFolderMembers } from '../viya-api-explorer/models/viya-api-folder-content.model'
import { VaMessagingService } from './va-messaging.service'
import { parseUserAgent } from '../shared/utils/parse-user-agent'
import { VERSION } from '../../environments/version'
@Injectable({
@@ -129,6 +130,7 @@ export class SasService {
if (data && typeof data === 'object' && !data.browser_info) {
const va = this.vaMessagingService.latestMessage()
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
const ua = parseUserAgent(navigator.userAgent)
data.browser_info = [
{
url: window.location.href,
@@ -136,7 +138,12 @@ export class SasService {
timezone: tz || '',
tz_offset: new Date().getTimezoneOffset(),
locale: navigator.language || '',
version: VERSION.semverString,
dc_version: VERSION.semverString,
adapter_version: VERSION.adapterVersion,
browser: ua.browser,
browser_version: ua.browserVersion,
platform: ua.platform,
user_agent: navigator.userAgent || '',
va_result_name: va?.resultName ?? '',
va_row_count: va?.rowCount ?? 0,
va_available_row_count: va?.availableRowCount ?? 0
@@ -147,7 +154,8 @@ export class SasService {
name: p.name,
label: p.label ?? '',
data_type: p.dataType ?? '',
value: p.value === null || p.value === undefined ? '' : String(p.value)
value:
p.value === null || p.value === undefined ? '' : String(p.value)
}))
}
if (va?.columns?.length) {
@@ -0,0 +1,50 @@
/**
* Summarises `navigator.userAgent` into the three things worth logging about a
* browser: its family, its version, and the platform it is running on.
*
* The raw user agent is kept alongside these - it is what a support ticket
* actually needs - but a hook is far more likely to want to branch on a name
* than to write its own user agent parsing.
*
* Order matters: Edge and Opera both claim to be Chrome, and Chrome claims to
* be Safari, so the most specific token has to be tested first.
*/
export interface UserAgentSummary {
browser: string
browserVersion: string
platform: string
}
export function parseUserAgent(userAgent: string): UserAgentSummary {
const ua = userAgent || ''
const match = (re: RegExp) => (ua.match(re) || [])[1] || ''
let browser = 'Unknown'
let browserVersion = ''
if (/Edg[A-Z]?\//.test(ua)) {
browser = 'Edge'
browserVersion = match(/Edg[A-Z]?\/([\d.]+)/)
} else if (/OPR\//.test(ua)) {
browser = 'Opera'
browserVersion = match(/OPR\/([\d.]+)/)
} else if (/Firefox\//.test(ua)) {
browser = 'Firefox'
browserVersion = match(/Firefox\/([\d.]+)/)
} else if (/Chrome\//.test(ua)) {
browser = 'Chrome'
browserVersion = match(/Chrome\/([\d.]+)/)
} else if (/Safari\//.test(ua)) {
browser = 'Safari'
browserVersion = match(/Version\/([\d.]+)/)
}
let platform = 'Unknown'
if (/Android/.test(ua)) platform = 'Android'
else if (/iPhone|iPad|iPod/.test(ua)) platform = 'iOS'
else if (/Windows/.test(ua)) platform = 'Windows'
else if (/Mac OS X/.test(ua)) platform = 'macOS'
else if (/Linux/.test(ua)) platform = 'Linux'
return { browser, browserVersion, platform }
}