diff --git a/openstudioreleases/.gitignore b/openstudioreleases/.gitignore index db6a5d7..1f76988 100644 --- a/openstudioreleases/.gitignore +++ b/openstudioreleases/.gitignore @@ -85,3 +85,5 @@ assets yarn.lock users.csv users.json +ga_events_*.csv +ga_events_*.json diff --git a/openstudioreleases/README.md b/openstudioreleases/README.md index 0de0c0d..c4773d8 100644 --- a/openstudioreleases/README.md +++ b/openstudioreleases/README.md @@ -180,3 +180,19 @@ yarn es scripts/syncReleaseAssets.ts # Set development or production set `NODE_ENV` in the `.env` to either "development" or "production" before making a build + +## Google Analytics Data Export + +Requires `firebase-service-account.json` and the [Google Analytics Data API](https://console.developers.google.com/apis/api/analyticsdata.googleapis.com/overview?project=891236007779) enabled. The service account also needs **Viewer** access on GA4 property `332455107` (Analytics Admin → Property Access Management). + +```bash +# List available dimensions +yarn ga-dimensions + +# Export select_content events (app tabs + downloads) for the last 90 days +yarn ga-events --startDate 90daysAgo --endDate today --eventName select_content +``` + +Output CSV/JSON files are written to this directory and gitignored. + + diff --git a/openstudioreleases/package.json b/openstudioreleases/package.json index 643a2e4..86458d1 100644 --- a/openstudioreleases/package.json +++ b/openstudioreleases/package.json @@ -5,6 +5,7 @@ "@emotion/react": "^11.13.0", "@emotion/styled": "^11.13.0", "@fontsource/roboto": "^5.0.13", + "@google-analytics/data": "^6.1.0", "@mui/icons-material": "^5.16.7", "@mui/material": "^5.16.7", "adm-zip": "^0.5.15", @@ -62,6 +63,8 @@ "es": "set ESBUILD_RUN=true && node -r esbuild-register -r ./scripts/patchGaxios.cjs", "s": "set ESBUILD_RUN=true && node -r @swc-node/register --trace-deprecation --trace-warnings", "ghpages": "yarn build && gh-pages -d dist", - "sync-releases": "yarn es scripts/getReleaseInfo.ts" + "sync-releases": "yarn es scripts/getReleaseInfo.ts", + "ga-events": "yarn s scripts/getAnalyticsEvents.ts", + "ga-dimensions": "yarn s scripts/listAnalyticsDimensions.ts" } } diff --git a/openstudioreleases/scripts/getAnalyticsEvents.ts b/openstudioreleases/scripts/getAnalyticsEvents.ts new file mode 100644 index 0000000..af7907c --- /dev/null +++ b/openstudioreleases/scripts/getAnalyticsEvents.ts @@ -0,0 +1,215 @@ +/** + * Export all events from GA4 property 332455107 using the Google Analytics Data API. + * + * Prerequisites: + * 1. The service account in firebase-service-account.json must have the + * "Viewer" role on the GA4 property (Google Analytics > Admin > Property Access Management). + * 2. The Google Analytics Data API must be enabled in the Google Cloud project. + * + * Usage: + * yarn ga-events + * yarn ga-events --startDate 2024-01-01 --endDate 2024-12-31 + * yarn ga-events --startDate 30daysAgo --endDate today + * yarn ga-events --eventName select_content # filter to one event + * + * The contentId dimension is a GA4 built-in populated by select_content events. + * It does not require custom dimension registration. + */ + +import { BetaAnalyticsDataClient } from '@google-analytics/data'; +import { writeJson } from './writeJson'; +import { stringify } from 'csv-stringify/sync'; +import fs from 'fs'; +import path from 'path'; + +// ── Configuration ───────────────────────────────────────────────────────────── + +const PROPERTY_ID = '332455107'; +const PAGE_SIZE = 10_000; // max rows per API request + +// Parse optional CLI date-range arguments (--startDate / --endDate) +const args = process.argv.slice(2); +const getArg = (flag: string, fallback: string): string => { + const idx = args.indexOf(flag); + return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback; +}; + +const startDate = getArg('--startDate', '2020-01-01'); +const endDate = getArg('--endDate', 'today'); +const eventFilter = getArg('--eventName', ''); // e.g. "select_content" + +// ── Legacy Tab-N normalization ──────────────────────────────────────────────── +// Older OpenStudio versions sent contentId as "Tab-N" using the VerticalTabID enum. +// Map them to the modern human-readable names so the export is consistent. +const LEGACY_TAB_MAP: Record = { + 'Tab-0': 'Location', // SITE + 'Tab-1': 'Schedules', // SCHEDULES + 'Tab-2': 'Constructions', // CONSTRUCTIONS + 'Tab-3': 'Loads', // LOADS + 'Tab-4': 'SpaceTypes', // SPACE_TYPES + 'Tab-5': 'Geometry', // GEOMETRY + 'Tab-6': 'Facility', // FACILITY + 'Tab-7': 'Spaces', // SPACES + 'Tab-8': 'Thermal Zones', // THERMAL_ZONES + 'Tab-9': 'HVAC Systems', // HVAC_SYSTEMS + 'Tab-10': 'Variables', // OUTPUT_VARIABLES + 'Tab-11': 'Sim Settings', // SIMULATION_SETTINGS + 'Tab-12': 'Scripts', // RUBY_SCRIPTS + 'Tab-13': 'Run', // RUN_SIMULATION + 'Tab-14': 'Results', // RESULTS_SUMMARY +}; + +const normalizeContentId = (id: string): string => LEGACY_TAB_MAP[id] ?? id; + +// ── GA4 client (uses service-account key file for auth) ─────────────────────── + +const analyticsClient = new BetaAnalyticsDataClient({ + keyFilename: path.resolve(__dirname, '..', 'firebase-service-account.json'), +}); + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface EventRow { + date: string; + eventName: string; + contentId: string; + platform: string; + country: string; + eventCount: number; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async function fetchAllRows(): Promise { + const allRows: EventRow[] = []; + let offset = 0; + + // Optionally restrict to a single event name + const dimensionFilter = eventFilter + ? { + filter: { + fieldName: 'eventName', + stringFilter: { matchType: 'EXACT' as const, value: eventFilter }, + }, + } + : undefined; + + while (true) { + const [response] = await analyticsClient.runReport({ + property: `properties/${PROPERTY_ID}`, + dateRanges: [{ startDate, endDate }], + dimensions: [ + { name: 'date' }, + { name: 'eventName' }, + // Built-in GA4 dimension populated by select_content events. + { name: 'contentId' }, + { name: 'platform' }, + { name: 'country' }, + ], + metrics: [ + { name: 'eventCount' }, + ], + dimensionFilter, + orderBys: [ + { dimension: { dimensionName: 'date' } }, + { dimension: { dimensionName: 'eventName' } }, + { metric: { metricName: 'eventCount' }, desc: true }, + ], + limit: PAGE_SIZE, + offset, + }); + + const rows = response.rows ?? []; + + for (const row of rows) { + const dims = row.dimensionValues ?? []; + const mets = row.metricValues ?? []; + allRows.push({ + date: dims[0]?.value ?? '', + eventName: dims[1]?.value ?? '', + contentId: normalizeContentId(dims[2]?.value ?? ''), + platform: dims[3]?.value ?? '', + country: dims[4]?.value ?? '', + eventCount: parseInt(mets[0]?.value ?? '0', 10), + }); + } + + console.log( + rows.length + ? ` Fetched rows ${offset + 1}–${offset + rows.length} (total so far: ${allRows.length})` + : ` Fetched 0 rows (total so far: ${allRows.length})` + ); + + // Stop when we receive fewer rows than the page size + if (rows.length < PAGE_SIZE) break; + offset += PAGE_SIZE; + } + + return allRows; +} + +function writeCsvFile(filename: string, rows: EventRow[]): void { + const csvPath = path.join(process.cwd(), `${filename}.csv`); + const header = ['date', 'eventName', 'contentId', 'platform', 'country', 'eventCount']; + + const csvString = stringify([ + header, + ...rows.map(r => [r.date, r.eventName, r.contentId, r.platform, r.country, r.eventCount]), + ]); + + fs.writeFileSync(csvPath, csvString, 'utf8'); + console.log(`CSV written to: ${csvPath}`); +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +const run = async (): Promise => { + console.log(`Fetching GA4 events for property ${PROPERTY_ID}`); + console.log(`Date range: ${startDate} → ${endDate}`); + if (eventFilter) console.log(`Event filter: ${eventFilter}`); + + const rows = await fetchAllRows(); + + console.log(`\nTotal rows: ${rows.length}`); + + const filterSuffix = eventFilter ? `_${eventFilter}` : ''; + const baseName = `ga_events_${PROPERTY_ID}${filterSuffix}_${startDate}_${endDate}`.replace(/[^a-z0-9_-]/gi, '_'); + + writeCsvFile(baseName, rows); + await writeJson(baseName, rows); + + // Print a quick summary: top 20 events by total count + const eventTotals = new Map(); + for (const r of rows) { + eventTotals.set(r.eventName, (eventTotals.get(r.eventName) ?? 0) + r.eventCount); + } + const sortedEvents = [...eventTotals.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20); + console.log('\nTop 20 events by total count:'); + sortedEvents.forEach(([name, count]) => console.log(` ${name}: ${count.toLocaleString()}`)); + + // Print top content_ids (for select_content or any filtered event) + const contentTotals = new Map(); + for (const r of rows) { + if (!r.contentId || r.contentId === '(not set)') continue; + contentTotals.set(r.contentId, (contentTotals.get(r.contentId) ?? 0) + r.eventCount); + } + if (contentTotals.size > 0) { + const sortedContent = [...contentTotals.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20); + console.log('\nTop 20 content_id values by event count:'); + sortedContent.forEach(([id, count]) => console.log(` ${id}: ${count.toLocaleString()}`)); + } + + // Warn about any contentId that is neither a known tab name nor an OpenStudio download + const KNOWN_TABS = new Set(['Location','Run','Results','Constructions','HVAC Systems','Schedules', + 'Geometry','Spaces','Thermal Zones','SpaceTypes','Loads','Scripts','Facility','Sim Settings','Variables']); + const unknown = [...contentTotals.keys()].filter(id => !KNOWN_TABS.has(id) && !id.startsWith('OpenStudio')); + if (unknown.length > 0) { + console.warn('\nWARNING: unexpected contentId values (may need LEGACY_TAB_MAP update):'); + unknown.forEach(id => console.warn(` ${id}: ${contentTotals.get(id)?.toLocaleString()} events`)); + } +}; + +run().catch((err) => { + console.error('Error fetching GA4 events:', err.message ?? err); + process.exit(1); +}); diff --git a/openstudioreleases/scripts/listAnalyticsDimensions.ts b/openstudioreleases/scripts/listAnalyticsDimensions.ts new file mode 100644 index 0000000..14543fa --- /dev/null +++ b/openstudioreleases/scripts/listAnalyticsDimensions.ts @@ -0,0 +1,64 @@ +/** + * List all dimensions (built-in + custom) available on GA4 property 332455107. + * Use this to find the correct dimension name for custom event parameters like content_id. + * + * Usage: + * yarn ga-dimensions + * yarn ga-dimensions --filter content # filter output by substring + */ + +import { BetaAnalyticsDataClient } from '@google-analytics/data'; +import path from 'path'; + +const PROPERTY_ID = '332455107'; + +const args = process.argv.slice(2); +const filterIdx = args.indexOf('--filter'); +const filterStr = filterIdx !== -1 && args[filterIdx + 1] ? args[filterIdx + 1].toLowerCase() : ''; + +const analyticsClient = new BetaAnalyticsDataClient({ + keyFilename: path.resolve(__dirname, '..', 'firebase-service-account.json'), +}); + +const run = async (): Promise => { + const [metadata] = await analyticsClient.getMetadata({ + name: `properties/${PROPERTY_ID}/metadata`, + }); + + const dims = metadata.dimensions ?? []; + + const filtered = filterStr + ? dims.filter(d => + d.apiName?.toLowerCase().includes(filterStr) || + d.uiName?.toLowerCase().includes(filterStr) || + d.description?.toLowerCase().includes(filterStr) + ) + : dims; + + // Separate custom dimensions from built-in + const custom = filtered.filter(d => d.customDefinition); + const builtin = filtered.filter(d => !d.customDefinition); + + if (custom.length > 0) { + console.log(`\n── Custom dimensions (${custom.length}) ──────────────────────`); + custom.forEach(d => { + console.log(` apiName : ${d.apiName}`); + console.log(` uiName : ${d.uiName}`); + console.log(` description: ${d.description ?? ''}`); + console.log(''); + }); + } else { + console.log('\n(no custom dimensions found' + (filterStr ? ` matching "${filterStr}"` : '') + ')'); + } + + console.log(`\n── Built-in dimensions (${builtin.length}) ─────────────────────`); + builtin.forEach(d => console.log(` ${(d.apiName ?? '').padEnd(45)} ${d.uiName ?? ''}`)); + + console.log(`\nTotal: ${filtered.length} dimensions (${custom.length} custom, ${builtin.length} built-in)` + + (filterStr ? ` matching "${filterStr}" — ${dims.length} total on property` : '')); +}; + +run().catch((err) => { + console.error('Error:', err.message ?? err); + process.exit(1); +});