Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
132 changes: 132 additions & 0 deletions charts/templates/configmap-nginx.yaml
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 8 additions & 0 deletions charts/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions charts/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ zoneAntiAffinity:
enabled: false
topologyKey: topology.kubernetes.io/zone

observability:
openTelemetry:
collectorEndpoint: "opentelemetry-collector.observability.svc:4318"

nodeSelector: {}
tolerations: []
affinity: {}
18 changes: 18 additions & 0 deletions conf.d/default.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 22 additions & 4 deletions gulpfile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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')
Expand All @@ -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 () {
Expand Down Expand Up @@ -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));
});
Loading
Loading