From 9ec2dd8d8c3dbe0ee18ac7358b0acf584e0b125c Mon Sep 17 00:00:00 2001 From: Viresh Soedhwa Date: Tue, 26 May 2026 15:40:54 -0700 Subject: [PATCH 1/8] chore: expand .gitattributes with explicit binary and text file handling --- .gitattributes | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index fcadb2c..c41fdd8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,22 @@ -* text eol=lf +# Normalize real text files automatically +* text=auto eol=lf + +# Binary assets - never line-ending normalize these +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.svg text eol=lf + +*.mp4 binary +*.mov binary +*.webm binary + +*.pdf binary +*.psd binary + +*.docx binary +*.xlsx binary +*.pptx binary \ No newline at end of file From ae112ce036c92e3bbbfa639259dfd936988a28a8 Mon Sep 17 00:00:00 2001 From: Viresh Soedhwa Date: Tue, 26 May 2026 15:47:51 -0700 Subject: [PATCH 2/8] feat: replace Plausible analytics with OpenTelemetry-based solution --- conf.d/default.conf | 11 +++ gulpfile.mjs | 26 ++++++- js/analytics/init.js | 150 ++++++++++++++++++++++++++++++++++++ js/page-setup.js | 28 ++++--- package.json | 8 ++ pages/index.html | 13 +--- pages/interactions.html | 14 +--- pages/knowledge-check.html | 14 +--- pages/learning-blocks.html | 14 +--- pages/marker-reference.html | 14 +--- pages/media.html | 14 +--- pages/tables.html | 14 +--- pages/text.html | 14 +--- 13 files changed, 217 insertions(+), 117 deletions(-) create mode 100644 js/analytics/init.js diff --git a/conf.d/default.conf b/conf.d/default.conf index 0e2c347..e49e23a 100644 --- a/conf.d/default.conf +++ b/conf.d/default.conf @@ -88,6 +88,17 @@ server { add_header Cache-Control "public, immutable"; } + # OTel log ingestion proxy + location = /v1/logs { + proxy_pass http://opentelemetry-collector.observability.svc:4318/v1/logs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Content-Type $content_type; + client_max_body_size 64k; + access_log off; + } + # Health endpoints (match probes) location = /healthz { access_log off; diff --git a/gulpfile.mjs b/gulpfile.mjs index 8c3f828..2f95a89 100644 --- a/gulpfile.mjs +++ b/gulpfile.mjs @@ -10,6 +10,7 @@ import browserSync from 'browser-sync'; import fileInclude from 'gulp-file-include'; import { partialProcessor } from './gulp-partial-processor.mjs'; import path from 'path'; +import esbuild from 'esbuild'; const bs = browserSync.create(); const sassCompiler = gulpSass(sass); @@ -48,13 +49,29 @@ function copyAssets() { .pipe(gulp.dest(htmlDest + '/assets')); } -// Copy JS files to dist directory +// Copy JS files to dist directory (excludes analytics source) function copyJS() { - return gulp.src('./js/**/*') + return gulp.src(['./js/**/*', '!./js/analytics/**']) .pipe(gulp.dest(htmlDest + '/js')) .pipe(bs.stream()); } +// Bundle OTel analytics module into a single IIFE file +function bundleAnalytics() { + return esbuild.build({ + entryPoints: ['./js/analytics/init.js'], + bundle: true, + format: 'iife', + minify: true, + sourcemap: false, + target: ['es2020'], + outfile: htmlDest + '/js/otel-analytics.js', + define: { + 'process.env.NODE_ENV': '"production"', + }, + }); +} + // Process partials to convert XML-like tags to HTML function processPartials() { return gulp.src('./partials/**/*.html') @@ -74,7 +91,7 @@ function html() { } // Build files once -gulp.task('build', gulp.series(processPartials, gulp.parallel(css, copyCSS, copyAssets, copyJS, html))); +gulp.task('build', gulp.series(processPartials, gulp.parallel(css, copyCSS, copyAssets, copyJS, bundleAnalytics, html))); // Run BrowserSync after HTML changes /* gulp.task("sync", function () { @@ -118,5 +135,6 @@ gulp.task('watch', function () { gulp.watch(htmlSources, gulp.series(html)); gulp.watch(sassSources, gulp.series(css, copyCSS)); gulp.watch('./assets/**/*', gulp.series(copyAssets)); - gulp.watch('./js/**/*', gulp.series(copyJS)); + gulp.watch(['./js/**/*', '!./js/analytics/**'], gulp.series(copyJS)); + gulp.watch('./js/analytics/**/*', gulp.series(bundleAnalytics)); }); diff --git a/js/analytics/init.js b/js/analytics/init.js new file mode 100644 index 0000000..fde1582 --- /dev/null +++ b/js/analytics/init.js @@ -0,0 +1,150 @@ +import { logs, SeverityNumber } from '@opentelemetry/api-logs'; +import { + LoggerProvider, + BatchLogRecordProcessor, + SimpleLogRecordProcessor, + ConsoleLogRecordExporter, +} from '@opentelemetry/sdk-logs'; +import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'; +import { resourceFromAttributes } from '@opentelemetry/resources'; +import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; +import { NavigationInstrumentation } from '@opentelemetry/browser-instrumentation/experimental/navigation'; +import { NavigationTimingInstrumentation } from '@opentelemetry/browser-instrumentation/experimental/navigation-timing'; +import { WebVitalsInstrumentation } from '@opentelemetry/browser-instrumentation/experimental/web-vitals'; +import { ErrorsInstrumentation } from '@opentelemetry/browser-instrumentation/experimental/errors'; +import pkg from '../../package.json'; + +const { version } = pkg; + +function getSessionId() { + var id = sessionStorage.getItem('otel_session_id'); + if (!id) { + id = crypto.randomUUID(); + sessionStorage.setItem('otel_session_id', id); + } + return id; +} + +function isProduction() { + return ( + typeof window !== 'undefined' && + window.location.hostname !== 'localhost' && + window.location.hostname !== '127.0.0.1' + ); +} + +function getPageType() { + var path = window.location.pathname || ''; + var trimmed = path.replace(/\/+$/, ''); + var last = trimmed.split('/').filter(Boolean).pop() || ''; + var name = last.replace(/\.html$/, ''); + if (!name || name === 'index') { + return 'home'; + } + return name; +} + +function init() { + var prod = isProduction(); + + var resource = resourceFromAttributes({ + [ATTR_SERVICE_NAME]: 'conversion-guide', + [ATTR_SERVICE_VERSION]: version, + }); + + var logExporter = prod + ? new OTLPLogExporter({ url: '/v1/logs' }) + : new ConsoleLogRecordExporter(); + + var processor = prod + ? new BatchLogRecordProcessor(logExporter) + : new SimpleLogRecordProcessor(logExporter); + + var loggerProvider = new LoggerProvider({ + resource, + processors: [processor], + }); + + logs.setGlobalLoggerProvider(loggerProvider); + + registerInstrumentations({ + instrumentations: [ + new NavigationInstrumentation(), + new NavigationTimingInstrumentation(), + new WebVitalsInstrumentation(), + new ErrorsInstrumentation(), + ], + }); + + var sessionId = getSessionId(); + var isNewSession = !sessionStorage.getItem('otel_session_started'); + var startTime = Date.now(); + + var commonAttributes = { + 'user_agent': navigator.userAgent, + 'screen_resolution': screen.width + 'x' + screen.height, + 'referrer': document.referrer || '', + 'session.id': sessionId, + }; + + if (isNewSession) { + sessionStorage.setItem('otel_session_started', '1'); + logEvent('session_start', { + timestamp: new Date().toISOString(), + ...commonAttributes, + }); + } + + logEvent('page_view', { + url: window.location.href, + page_type: getPageType(), + ...commonAttributes, + }); + + // Session heartbeat every 60s + setInterval(function () { + if (!document.hidden) { + logEvent('session_heartbeat', { + 'duration_seconds': String(Math.round((Date.now() - startTime) / 1000)), + ...commonAttributes, + }); + } + }, 60000); +} + +function logEvent(eventName, attributes) { + try { + var logger = logs.getLogger('analytics'); + logger.emit({ + body: eventName, + severityNumber: SeverityNumber.INFO, + severityText: 'INFO', + attributes: { 'event.name': eventName, ...attributes }, + }); + } catch (e) { + if (!isProduction()) { + console.debug('[otel-analytics] logEvent failed', e); + } + } +} + +function trackEvent(eventName, attributes) { + var sessionId = getSessionId(); + logEvent(eventName, { + 'session.id': sessionId, + 'user_agent': navigator.userAgent, + 'screen_resolution': screen.width + 'x' + screen.height, + 'referrer': document.referrer || '', + ...attributes, + }); +} + +// Auto-init on load +init(); + +// Expose global API for use in page-setup.js +window.otelAnalytics = { + logEvent: logEvent, + trackEvent: trackEvent, +}; diff --git a/js/page-setup.js b/js/page-setup.js index 1282314..36ccabd 100644 --- a/js/page-setup.js +++ b/js/page-setup.js @@ -57,6 +57,15 @@ $(this).toggleClass("open"); var target = $(this).data("target"); $(this).parents("section").find(target).trigger("button-pressed"); + if (window.otelAnalytics && typeof window.otelAnalytics.trackEvent === "function") { + var viewMode = (target || "").replace(/^\./, ""); + var elementIndex = $(this).parents("section").index(); + window.otelAnalytics.trackEvent("view_toggle", { + page_type: (window.location.pathname || "").split("/").filter(Boolean).pop() || "home", + view_mode: viewMode, + element_index: String(elementIndex) + }); + } }); @@ -115,7 +124,7 @@ var newUrl = window.location.pathname + window.location.search + "#" + sectionId; window.history.replaceState(null, "", newUrl); if (sectionId !== lastTrackedSection) { - trackPlausible("content_section", { + trackEvent("content_section", { page_type: pageType, content_section: sectionId }); @@ -292,7 +301,7 @@ } }); - // Plausible tracking for custom properties + // Analytics tracking var pageType = getPageType(); var lastTrackedSection = null; @@ -307,11 +316,10 @@ return name; } - function trackPlausible(eventName, props) { - if (typeof window.plausible !== "function") { - return; + function trackEvent(eventName, attrs) { + if (window.otelAnalytics && typeof window.otelAnalytics.trackEvent === "function") { + window.otelAnalytics.trackEvent(eventName, attrs || {}); } - window.plausible(eventName, { props: props || {} }); } function getContentSectionFromHref(href) { @@ -397,14 +405,14 @@ if (section) { props.content_section = section; } - trackPlausible("nav_click", props); + trackEvent("nav_click", props); }); $(document).on("click", "a[href]", function () { var href = $(this).attr("href") || ""; var section = getContentSectionFromHref(href); if (section) { - trackPlausible("content_section", { + trackEvent("content_section", { page_type: pageType, content_section: section }); @@ -412,7 +420,7 @@ var assetInfo = getAssetInfo(href); if (assetInfo) { - trackPlausible("asset_download", { + trackEvent("asset_download", { page_type: pageType, asset_type: assetInfo.type, asset_name: assetInfo.name @@ -421,7 +429,7 @@ var external = getExternalLinkValue(href); if (external) { - trackPlausible("external_click", { + trackEvent("external_click", { page_type: pageType, external_link: external }); diff --git a/package.json b/package.json index bc973b3..b93e0cf 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,13 @@ "build": "gulp build" }, "dependencies": { + "@opentelemetry/api-logs": "^0.218.0", + "@opentelemetry/browser-instrumentation": "^0.5.2", + "@opentelemetry/exporter-logs-otlp-http": "^0.218.0", + "@opentelemetry/instrumentation": "^0.218.0", + "@opentelemetry/resources": "^2.7.1", + "@opentelemetry/sdk-logs": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.41.1", "browser-sync": "^3.0.2", "gulp": "^5.0.0", "gulp-autoprefixer": "^9.0.0", @@ -24,6 +31,7 @@ "sass": "^1.77.8" }, "devDependencies": { + "esbuild": "^0.28.0", "gulp-file-include": "^2.3.0", "through2": "^4.0.2" } diff --git a/pages/index.html b/pages/index.html index b2adabe..af3271e 100644 --- a/pages/index.html +++ b/pages/index.html @@ -15,18 +15,6 @@ - - - @@ -757,6 +745,7 @@

Templates

+ diff --git a/pages/interactions.html b/pages/interactions.html index e6d89b2..d83b916 100644 --- a/pages/interactions.html +++ b/pages/interactions.html @@ -23,19 +23,6 @@ padding-top: 27px; } - - - - @@ -63,6 +50,7 @@

Interactions

+ diff --git a/pages/knowledge-check.html b/pages/knowledge-check.html index bae67c6..07c105d 100644 --- a/pages/knowledge-check.html +++ b/pages/knowledge-check.html @@ -16,19 +16,6 @@ - - - - @@ -52,6 +39,7 @@

Knowledge Checks

+ diff --git a/pages/learning-blocks.html b/pages/learning-blocks.html index 99d807f..f52841b 100644 --- a/pages/learning-blocks.html +++ b/pages/learning-blocks.html @@ -17,19 +17,6 @@ - - - - @@ -67,6 +54,7 @@

Learning Blocks

+ diff --git a/pages/marker-reference.html b/pages/marker-reference.html index 390b27b..837b98d 100644 --- a/pages/marker-reference.html +++ b/pages/marker-reference.html @@ -37,19 +37,6 @@ } - - - - @@ -362,6 +349,7 @@

Marker Reference

+ diff --git a/pages/media.html b/pages/media.html index 938d58d..37f93d2 100644 --- a/pages/media.html +++ b/pages/media.html @@ -17,19 +17,6 @@ - - - - @@ -54,6 +41,7 @@

Media

+ diff --git a/pages/tables.html b/pages/tables.html index 4501f0f..ccac0cd 100644 --- a/pages/tables.html +++ b/pages/tables.html @@ -17,19 +17,6 @@ - - - - @@ -52,6 +39,7 @@

Tables

+ diff --git a/pages/text.html b/pages/text.html index a79311a..5b034a9 100644 --- a/pages/text.html +++ b/pages/text.html @@ -17,19 +17,6 @@ - - - - @@ -55,6 +42,7 @@

Text

+ From e4c25a41f0e0b7df419c02b0fc139eef606dd100 Mon Sep 17 00:00:00 2001 From: Viresh Soedhwa Date: Thu, 28 May 2026 10:03:49 -0700 Subject: [PATCH 3/8] chore: add explicit stage name to production Dockerfile stage --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 3ac04d5..f8ef90a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN npm run build # Release/production -FROM nginxinc/nginx-unprivileged:alpine3.22-perl +FROM nginxinc/nginx-unprivileged:alpine3.22-perl AS release LABEL maintainer=courseproduction@bcit.ca LABEL org.opencontainers.image.source="https://github.com/bcit-tlu/conversion-guide" From 5e82e03e26526db46373dc501c5e3cc4eb17dcba Mon Sep 17 00:00:00 2001 From: Viresh Soedhwa Date: Thu, 28 May 2026 10:06:02 -0700 Subject: [PATCH 4/8] fix: add DNS resolver to OTel logs proxy for dynamic service discovery --- conf.d/default.conf | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/conf.d/default.conf b/conf.d/default.conf index e49e23a..f42d07a 100644 --- a/conf.d/default.conf +++ b/conf.d/default.conf @@ -90,7 +90,9 @@ server { # OTel log ingestion proxy location = /v1/logs { - proxy_pass http://opentelemetry-collector.observability.svc:4318/v1/logs; + resolver 127.0.0.11 valid=30s ipv6=off; + set $otel_backend "opentelemetry-collector.observability.svc:4318"; + proxy_pass http://$otel_backend/v1/logs; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; From 8b9fc568c5ad3f3b58c54b82de704d1bdbe16c0c Mon Sep 17 00:00:00 2001 From: Viresh Soedhwa Date: Thu, 28 May 2026 13:12:12 -0700 Subject: [PATCH 5/8] docs: update analytics documentation and refine OTel tracking implementation --- AGENTS.md | 2 +- charts/templates/configmap-nginx.yaml | 132 ++++++++++++++++++++++++++ charts/templates/deployment.yaml | 8 ++ charts/values.yaml | 3 + conf.d/default.conf | 4 +- js/analytics/init.js | 26 ++++- js/page-setup.js | 53 ++++++----- 7 files changed, 194 insertions(+), 34 deletions(-) create mode 100644 charts/templates/configmap-nginx.yaml diff --git a/AGENTS.md b/AGENTS.md index b79fc42..7d3f123 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ - **Runtime**: Nginx-unprivileged on port 8080 (static site served by nginx) - **Build**: Gulp pipeline compiles SCSS, assembles HTML partials, and outputs to `dist/` -- **Analytics**: Plausible integration for privacy-focused usage tracking +- **Analytics**: OpenTelemetry browser instrumentation for usage analytics (logs exported via OTLP to Loki/Grafana) - **Health endpoint**: Nginx responds to probe requests on `/` ## Development Workflow diff --git a/charts/templates/configmap-nginx.yaml b/charts/templates/configmap-nginx.yaml new file mode 100644 index 0000000..400c60e --- /dev/null +++ b/charts/templates/configmap-nginx.yaml @@ -0,0 +1,132 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "conversion-guide.fullname" . }}-nginx + labels: + {{- include "conversion-guide.labels" . | nindent 4 }} +data: + default.conf: | + # ---------------------------------------- + # HTTP-level logging & helpers + # ---------------------------------------- + + # Flag kubelet health probes by User-Agent + map $http_user_agent $is_probe { default 0; ~kube-probe 1; } + + # Flag error statuses + map $status $is_error { default 0; ~^[45] 1; } + + # Only log failing probe requests + map "$is_probe:$is_error" $log_probe_fail { default 0; "1:1" 1; } + + # Log all non-probe requests (browsers/APIs) + map $is_probe $not_probe { 0 1; 1 0; } + + # JSON access log for normal traffic + log_format json escape=json + '{' + '"ts":"$time_iso8601",' + '"remote":"$remote_addr",' + '"method":"$request_method",' + '"uri":"$request_uri",' + '"status":$status,' + '"bytes":$body_bytes_sent,' + '"rt":$request_time,' + '"ref":"$http_referer",' + '"ua":"$http_user_agent",' + '"req_id":"$request_id"' + '}'; + + # Compact format for probe logs + log_format probe '$remote_addr - $time_local "$request" $status rt=$request_time'; + + # Send error logs to stderr + error_log /dev/stderr warn; + + + # ---------------------------------------- + # Main server + # ---------------------------------------- + server { + listen 8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + absolute_redirect off; + port_in_redirect off; + server_name_in_redirect off; + + # Access logging: + access_log /dev/stdout json if=$not_probe; # all non-probe requests + access_log /dev/stdout probe if=$log_probe_fail; # ONLY failing probes + + # Include MIME types + include /etc/nginx/mime.types; + + # On-the-fly compression for text-heavy responses + gzip on; + gzip_types text/plain text/css text/javascript application/javascript application/json image/svg+xml; + gzip_min_length 256; + gzip_vary on; + + # Serve pre-compressed .gz files when available (forward-looking) + gzip_static on; + + # Serve clean URLs without .html extension + location / { + index index.html; + try_files $uri $uri.html $uri/ =404; + } + + # Serve static assets + location /assets/ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + + location /css/ { + expires 1w; + add_header Cache-Control "public, immutable"; + } + + location /js/ { + expires 1w; + add_header Cache-Control "public, immutable"; + } + + # OTel log ingestion proxy + location = /v1/logs { + proxy_pass http://{{ .Values.otel.collectorEndpoint }}/v1/logs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Content-Type $content_type; + client_max_body_size 64k; + access_log off; + } + + # Health endpoints (match probes) + location = /healthz { + access_log off; + add_header Content-Type application/json; + return 200 '{"status":"HEALTHY"}'; + } + + location = /healthz/startup { + access_log off; + add_header Content-Type application/json; + return 200 '{"status":"STARTUP_OK"}'; + } + + location = /healthz/ready { + access_log off; + add_header Content-Type application/json; + return 200 '{"status":"READY_OK"}'; + } + + # Optional nice to haves + sendfile on; + keepalive_timeout 65s; + } diff --git a/charts/templates/deployment.yaml b/charts/templates/deployment.yaml index 8dd7a77..6956fb2 100644 --- a/charts/templates/deployment.yaml +++ b/charts/templates/deployment.yaml @@ -57,7 +57,15 @@ spec: port: http initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }} periodSeconds: {{ .Values.probes.readiness.periodSeconds }} + volumeMounts: + - name: nginx-conf + mountPath: /etc/nginx/conf.d + readOnly: true {{- with .Values.resources }} resources: {{- toYaml . | nindent 12 }} {{- end }} + volumes: + - name: nginx-conf + configMap: + name: {{ include "conversion-guide.fullname" . }}-nginx diff --git a/charts/values.yaml b/charts/values.yaml index 71bea5e..39af72d 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -45,6 +45,9 @@ zoneAntiAffinity: enabled: false topologyKey: topology.kubernetes.io/zone +otel: + collectorEndpoint: "opentelemetry-collector.observability.svc:4318" + nodeSelector: {} tolerations: [] affinity: {} diff --git a/conf.d/default.conf b/conf.d/default.conf index f42d07a..e49e23a 100644 --- a/conf.d/default.conf +++ b/conf.d/default.conf @@ -90,9 +90,7 @@ server { # OTel log ingestion proxy location = /v1/logs { - resolver 127.0.0.11 valid=30s ipv6=off; - set $otel_backend "opentelemetry-collector.observability.svc:4318"; - proxy_pass http://$otel_backend/v1/logs; + proxy_pass http://opentelemetry-collector.observability.svc:4318/v1/logs; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/js/analytics/init.js b/js/analytics/init.js index fde1582..d3eac8c 100644 --- a/js/analytics/init.js +++ b/js/analytics/init.js @@ -29,8 +29,7 @@ function getSessionId() { function isProduction() { return ( typeof window !== 'undefined' && - window.location.hostname !== 'localhost' && - window.location.hostname !== '127.0.0.1' + window.location.hostname.endsWith('.ltc.bcit.ca') ); } @@ -45,6 +44,8 @@ function getPageType() { return name; } +var _loggerProvider = null; + function init() { var prod = isProduction(); @@ -61,12 +62,12 @@ function init() { ? new BatchLogRecordProcessor(logExporter) : new SimpleLogRecordProcessor(logExporter); - var loggerProvider = new LoggerProvider({ + _loggerProvider = new LoggerProvider({ resource, processors: [processor], }); - logs.setGlobalLoggerProvider(loggerProvider); + logs.setGlobalLoggerProvider(_loggerProvider); registerInstrumentations({ instrumentations: [ @@ -103,7 +104,7 @@ function init() { }); // Session heartbeat every 60s - setInterval(function () { + var heartbeatInterval = setInterval(function () { if (!document.hidden) { logEvent('session_heartbeat', { 'duration_seconds': String(Math.round((Date.now() - startTime) / 1000)), @@ -111,6 +112,20 @@ function init() { }); } }, 60000); + + // Flush pending logs and emit session_end on tab close / navigate away + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'hidden') { + clearInterval(heartbeatInterval); + logEvent('session_end', { + 'duration_seconds': String(Math.round((Date.now() - startTime) / 1000)), + ...commonAttributes, + }); + if (_loggerProvider) { + _loggerProvider.forceFlush(); + } + } + }); } function logEvent(eventName, attributes) { @@ -147,4 +162,5 @@ init(); window.otelAnalytics = { logEvent: logEvent, trackEvent: trackEvent, + getPageType: getPageType, }; diff --git a/js/page-setup.js b/js/page-setup.js index 36ccabd..15bab4b 100644 --- a/js/page-setup.js +++ b/js/page-setup.js @@ -57,15 +57,13 @@ $(this).toggleClass("open"); var target = $(this).data("target"); $(this).parents("section").find(target).trigger("button-pressed"); - if (window.otelAnalytics && typeof window.otelAnalytics.trackEvent === "function") { - var viewMode = (target || "").replace(/^\./, ""); - var elementIndex = $(this).parents("section").index(); - window.otelAnalytics.trackEvent("view_toggle", { - page_type: (window.location.pathname || "").split("/").filter(Boolean).pop() || "home", - view_mode: viewMode, - element_index: String(elementIndex) - }); - } + var viewMode = (target || "").replace(/^\./, ""); + var elementIndex = $(this).parents("section").index(); + trackEvent("view_toggle", { + page_type: pageType, + view_mode: viewMode, + element_index: String(elementIndex) + }); }); @@ -302,20 +300,20 @@ }); // Analytics tracking - var pageType = getPageType(); + var pageType = (window.otelAnalytics && typeof window.otelAnalytics.getPageType === "function") + ? window.otelAnalytics.getPageType() + : (function () { + var path = window.location.pathname || ""; + var trimmed = path.replace(/\/+$/, ""); + var last = trimmed.split("/").filter(Boolean).pop() || ""; + var name = last.replace(/\.html$/, ""); + if (!name || name === "index") { + return "home"; + } + return name; + }()); var lastTrackedSection = null; - function getPageType() { - var path = window.location.pathname || ""; - var trimmed = path.replace(/\/+$/, ""); - var last = trimmed.split("/").filter(Boolean).pop() || ""; - var name = last.replace(/\.html$/, ""); - if (!name || name === "index") { - return "home"; - } - return name; - } - function trackEvent(eventName, attrs) { if (window.otelAnalytics && typeof window.otelAnalytics.trackEvent === "function") { window.otelAnalytics.trackEvent(eventName, attrs || {}); @@ -410,12 +408,17 @@ $(document).on("click", "a[href]", function () { var href = $(this).attr("href") || ""; + var isNav = $(this).closest(".menu, .nav-bar").length > 0; + var section = getContentSectionFromHref(href); if (section) { - trackEvent("content_section", { - page_type: pageType, - content_section: section - }); + lastTrackedSection = section; + if (!isNav) { + trackEvent("content_section", { + page_type: pageType, + content_section: section + }); + } } var assetInfo = getAssetInfo(href); From 18031f32c1ad4bbad8e3c7db2af6a4aff3d7266d Mon Sep 17 00:00:00 2001 From: Viresh Soedhwa Date: Thu, 28 May 2026 13:21:33 -0700 Subject: [PATCH 6/8] refactor: restructure OTel configuration under observability namespace --- charts/templates/configmap-nginx.yaml | 2 +- charts/values.yaml | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/charts/templates/configmap-nginx.yaml b/charts/templates/configmap-nginx.yaml index 400c60e..002c6a2 100644 --- a/charts/templates/configmap-nginx.yaml +++ b/charts/templates/configmap-nginx.yaml @@ -98,7 +98,7 @@ data: # OTel log ingestion proxy location = /v1/logs { - proxy_pass http://{{ .Values.otel.collectorEndpoint }}/v1/logs; + proxy_pass http://{{ .Values.observability.openTelemetry.collectorEndpoint }}/v1/logs; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/charts/values.yaml b/charts/values.yaml index 39af72d..b3574bd 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -45,8 +45,9 @@ zoneAntiAffinity: enabled: false topologyKey: topology.kubernetes.io/zone -otel: - collectorEndpoint: "opentelemetry-collector.observability.svc:4318" +observability: + openTelemetry: + collectorEndpoint: "opentelemetry-collector.observability.svc:4318" nodeSelector: {} tolerations: [] From 7710cb2660577be4071616ce8d733706101b729c Mon Sep 17 00:00:00 2001 From: Viresh Soedhwa Date: Thu, 28 May 2026 15:37:42 -0700 Subject: [PATCH 7/8] fix: defer OTel collector DNS resolution to request time for Docker compatibility --- conf.d/default.conf | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/conf.d/default.conf b/conf.d/default.conf index e49e23a..dbcd166 100644 --- a/conf.d/default.conf +++ b/conf.d/default.conf @@ -89,8 +89,15 @@ server { } # OTel log ingestion proxy + # Variable + resolver defers DNS to request time so nginx starts + # even when the collector is unreachable (e.g. local Docker dev). + # 127.0.0.11 is Docker's embedded DNS resolver. + # In K8s this block is replaced by the Helm ConfigMap, which uses + # a direct proxy_pass (service DNS always resolvable at startup). location = /v1/logs { - proxy_pass http://opentelemetry-collector.observability.svc:4318/v1/logs; + resolver 127.0.0.11 valid=30s ipv6=off; + set $otel_backend "opentelemetry-collector.observability.svc:4318"; + proxy_pass http://$otel_backend/v1/logs; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; From 8df92afb7014de0b4f0896555198ba8cceb4af39 Mon Sep 17 00:00:00 2001 From: Arvin Rolos Date: Fri, 29 May 2026 10:41:38 -0700 Subject: [PATCH 8/8] eliminate scroll-driven analytics noise; add section header highlight for nav feedback --- js/page-setup.js | 75 ++++++++++++++++++++++++------------- scss/partials/_wrapper.scss | 6 +++ 2 files changed, 54 insertions(+), 27 deletions(-) diff --git a/js/page-setup.js b/js/page-setup.js index 15bab4b..e39edf4 100644 --- a/js/page-setup.js +++ b/js/page-setup.js @@ -67,22 +67,6 @@ }); - // Scrolling event handler - $(window).on("scrollTo", function (e, id) { - $("html,body").stop().animate({ - scrollTop: getOffset(id) - 50 - }, 300); - - function getOffset(id) { - var $target = $("[id='" + id + "']"); - if ($target.length === 1) { - return $target.offset().top - 10; - } - return 0; - } - }); - - // Move menu highlighting on scroll $(window).on("scroll", function () { // TODO: Debounce function to reduce processing cost @@ -260,17 +244,6 @@ } }); - // Trigger scrolling - $("a[href*='#']").on("click", function () { - var href = $(this).attr("href"); - var pathname = href.split("#").shift(); - var id = href.split("#").pop(); - - if (window.location.pathname.indexOf(pathname) !== -1) { - $(window).trigger("scrollTo", id); - } - }); - $menu.trigger("adjust-size"); } @@ -331,6 +304,36 @@ return decodeURIComponent(hash); } + // Highlight the header (or first heading) of the section the user + // jumped to with a brief blue background tint. + function highlightSection(sectionId) { + if (!sectionId) { + return; + } + var section = document.getElementById(sectionId); + if (!section) { + return; + } + var target = section.querySelector("header") || + section.querySelector("h1, h2, h3, h4, h5, h6"); + if (!target) { + return; + } + target.animate( + [ + { backgroundColor: 'rgba(0, 163, 224, 0.20)' }, + { backgroundColor: 'transparent' } + ], + { duration: 2000, easing: 'ease-out' } + ); + } + + // Cross-page nav links trigger a full page load, so highlight the + // landed section on startup. + if (window.location.hash) { + highlightSection(decodeURIComponent(window.location.hash.slice(1))); + } + function getAssetInfo(href) { if (!href) { return null; @@ -404,6 +407,23 @@ props.content_section = section; } trackEvent("nav_click", props); + + // Highlight the landed section on same-page jumps only. + var linkPath = href.split("#").shift(); + var currentPath = window.location.pathname; + if (linkPath === "/") { + linkPath = currentPath; + } + if (section && linkPath === currentPath) { + highlightSection(section); + } + + // On mobile the menu overlays the content, so close it after a + // nav tap to reveal the section the user just jumped to. + if ($(window).width() < 1100) { + $(".menu").trigger("close"); + $(".menu-overlay").css({ "visibility": "hidden", "opacity": "0" }); + } }); $(document).on("click", "a[href]", function () { @@ -418,6 +438,7 @@ page_type: pageType, content_section: section }); + highlightSection(section); } } diff --git a/scss/partials/_wrapper.scss b/scss/partials/_wrapper.scss index 81b4cf0..d944b62 100644 --- a/scss/partials/_wrapper.scss +++ b/scss/partials/_wrapper.scss @@ -11,6 +11,12 @@ body { overflow-x: hidden; } +// Offset the instant jump so the heading isn't hidden under the +// fixed nav bar (replaces the old animate `-50` offset). +.wrapper > section[id] { + scroll-margin-top: 60px; +} + .wrapper>header { text-align: center; // padding-top: $header-padding-top;