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 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/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" diff --git a/charts/templates/configmap-nginx.yaml b/charts/templates/configmap-nginx.yaml new file mode 100644 index 0000000..002c6a2 --- /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.observability.openTelemetry.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..b3574bd 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -45,6 +45,10 @@ zoneAntiAffinity: enabled: false topologyKey: topology.kubernetes.io/zone +observability: + openTelemetry: + collectorEndpoint: "opentelemetry-collector.observability.svc:4318" + nodeSelector: {} tolerations: [] affinity: {} diff --git a/conf.d/default.conf b/conf.d/default.conf index 0e2c347..dbcd166 100644 --- a/conf.d/default.conf +++ b/conf.d/default.conf @@ -88,6 +88,24 @@ server { add_header Cache-Control "public, immutable"; } + # 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 { + 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; + 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..d3eac8c --- /dev/null +++ b/js/analytics/init.js @@ -0,0 +1,166 @@ +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.endsWith('.ltc.bcit.ca') + ); +} + +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; +} + +var _loggerProvider = null; + +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); + + _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 + var heartbeatInterval = setInterval(function () { + if (!document.hidden) { + logEvent('session_heartbeat', { + 'duration_seconds': String(Math.round((Date.now() - startTime) / 1000)), + ...commonAttributes, + }); + } + }, 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) { + 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, + getPageType: getPageType, +}; diff --git a/js/page-setup.js b/js/page-setup.js index 1282314..e39edf4 100644 --- a/js/page-setup.js +++ b/js/page-setup.js @@ -57,22 +57,13 @@ $(this).toggleClass("open"); var target = $(this).data("target"); $(this).parents("section").find(target).trigger("button-pressed"); - }); - - - // 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; - } + var viewMode = (target || "").replace(/^\./, ""); + var elementIndex = $(this).parents("section").index(); + trackEvent("view_toggle", { + page_type: pageType, + view_mode: viewMode, + element_index: String(elementIndex) + }); }); @@ -115,7 +106,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 }); @@ -253,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"); } @@ -292,26 +272,25 @@ } }); - // Plausible tracking for custom properties - var pageType = getPageType(); + // Analytics tracking + 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"; + function trackEvent(eventName, attrs) { + if (window.otelAnalytics && typeof window.otelAnalytics.trackEvent === "function") { + window.otelAnalytics.trackEvent(eventName, attrs || {}); } - return name; - } - - function trackPlausible(eventName, props) { - if (typeof window.plausible !== "function") { - return; - } - window.plausible(eventName, { props: props || {} }); } function getContentSectionFromHref(href) { @@ -325,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; @@ -397,22 +406,45 @@ if (section) { props.content_section = section; } - trackPlausible("nav_click", props); + 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 () { var href = $(this).attr("href") || ""; + var isNav = $(this).closest(".menu, .nav-bar").length > 0; + var section = getContentSectionFromHref(href); if (section) { - trackPlausible("content_section", { - page_type: pageType, - content_section: section - }); + lastTrackedSection = section; + if (!isNav) { + trackEvent("content_section", { + page_type: pageType, + content_section: section + }); + highlightSection(section); + } } 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 +453,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

+ 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;