@aurostack/stacks 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +84 -0
  2. package/cli/src/generate.mjs +13 -1
  3. package/cli/src/hooks.mjs +10 -3
  4. package/cli/stack.mjs +3 -0
  5. package/package.json +4 -1
  6. package/templates/nest-api/files/.env.example +29 -2
  7. package/templates/nest-api/files/.env.test.example +1 -1
  8. package/templates/nest-api/files/compose.yml +16 -0
  9. package/templates/nest-api/files/package.json +19 -4
  10. package/templates/nest-api/files/src/app.module.ts +9 -1
  11. package/templates/nest-api/files/src/app.setup.ts +5 -6
  12. package/templates/nest-api/files/src/common/__tests__/temporal.service.spec.ts +66 -0
  13. package/templates/nest-api/files/src/common/common.module.ts +4 -3
  14. package/templates/nest-api/files/src/common/controllers/index.ts +0 -1
  15. package/templates/nest-api/files/src/common/modules/index.ts +0 -1
  16. package/templates/nest-api/files/src/common/modules/logger.module.ts +2 -6
  17. package/templates/nest-api/files/src/common/services/config.service.ts +43 -5
  18. package/templates/nest-api/files/src/common/services/index.ts +1 -0
  19. package/templates/nest-api/files/src/common/services/temporal.service.ts +66 -0
  20. package/templates/nest-api/files/src/instrumentation.ts +117 -0
  21. package/templates/nest-api/files/src/main.ts +3 -1
  22. package/templates/nest-api/files/src/telemetry/__tests__/config.spec.ts +67 -0
  23. package/templates/nest-api/files/src/telemetry/config.ts +62 -0
  24. package/templates/nest-api/template.json +37 -11
  25. package/templates/node-worker/files/.env.example +27 -0
  26. package/templates/node-worker/files/.prettierignore +2 -0
  27. package/templates/node-worker/files/Dockerfile +25 -2
  28. package/templates/node-worker/files/Dockerfile.dev +25 -2
  29. package/templates/node-worker/files/ecosystem.config.cjs +2 -0
  30. package/templates/node-worker/files/package.json +23 -4
  31. package/templates/node-worker/files/src/config.ts +23 -0
  32. package/templates/node-worker/files/src/index.ts +13 -0
  33. package/templates/node-worker/files/src/instrumentation.ts +110 -0
  34. package/templates/node-worker/files/src/telemetry/config.ts +61 -0
  35. package/templates/node-worker/files/src/temporal/activities/index.ts +14 -0
  36. package/templates/node-worker/files/src/temporal/worker.ts +84 -0
  37. package/templates/node-worker/files/src/temporal/workflows/index.ts +23 -0
  38. package/templates/node-worker/files/src/utils/worker.ts +9 -1
  39. package/templates/node-worker/template.json +44 -2
  40. package/templates/py-worker/files/.env.example +28 -0
  41. package/templates/py-worker/files/config.py +32 -1
  42. package/templates/py-worker/files/requirements.txt +16 -0
  43. package/templates/py-worker/files/temporal/__init__.py +0 -0
  44. package/templates/py-worker/files/temporal/activities.py +13 -0
  45. package/templates/py-worker/files/temporal/workflows.py +38 -0
  46. package/templates/py-worker/files/utils/telemetry.py +154 -0
  47. package/templates/py-worker/files/utils/temporal.py +55 -0
  48. package/templates/py-worker/files/utils/worker.py +12 -1
  49. package/templates/py-worker/files/worker.py +16 -0
  50. package/templates/py-worker/template.json +36 -0
  51. package/templates/react-app/derive.sh +2 -1
  52. package/templates/react-app/files/.env.example +11 -0
  53. package/templates/react-app/files/package.json +2 -0
  54. package/templates/react-app/files/src/lib/env.ts +10 -0
  55. package/templates/react-app/files/src/main.tsx +4 -0
  56. package/templates/react-app/files/src/shared/auth/provider.tsx +2 -0
  57. package/templates/react-app/files/src/shared/layouts/error-boundary.tsx +2 -0
  58. package/templates/react-app/files/src/shared/telemetry/index.ts +80 -0
  59. package/templates/react-app/template.json +15 -1
  60. package/templates/react-monorepo/files/apps/admin/.env.example +11 -0
  61. package/templates/react-monorepo/files/apps/admin/package.json +1 -0
  62. package/templates/react-monorepo/files/apps/admin/src/lib/env.ts +10 -0
  63. package/templates/react-monorepo/files/apps/admin/src/main.tsx +4 -0
  64. package/templates/react-monorepo/files/apps/auth/.env.example +11 -0
  65. package/templates/react-monorepo/files/apps/auth/package.json +1 -0
  66. package/templates/react-monorepo/files/apps/auth/src/lib/env.ts +10 -0
  67. package/templates/react-monorepo/files/apps/auth/src/main.tsx +4 -0
  68. package/templates/react-monorepo/files/apps/client/.env.example +11 -0
  69. package/templates/react-monorepo/files/apps/client/package.json +1 -0
  70. package/templates/react-monorepo/files/apps/client/src/lib/env.ts +10 -0
  71. package/templates/react-monorepo/files/apps/client/src/main.tsx +4 -0
  72. package/templates/react-monorepo/files/apps/landing/.env.example +11 -0
  73. package/templates/react-monorepo/files/apps/landing/package.json +1 -0
  74. package/templates/react-monorepo/files/apps/landing/src/lib/env.ts +10 -0
  75. package/templates/react-monorepo/files/apps/landing/src/main.tsx +4 -0
  76. package/templates/react-monorepo/files/packages/auth/package.json +1 -0
  77. package/templates/react-monorepo/files/packages/auth/src/provider.tsx +2 -0
  78. package/templates/react-monorepo/files/packages/layouts/package.json +1 -0
  79. package/templates/react-monorepo/files/packages/layouts/src/error-boundary.tsx +2 -0
  80. package/templates/react-monorepo/files/packages/telemetry/eslint.config.mjs +3 -0
  81. package/templates/react-monorepo/files/packages/telemetry/package.json +20 -0
  82. package/templates/react-monorepo/files/packages/telemetry/src/index.ts +80 -0
  83. package/templates/react-monorepo/files/packages/telemetry/tsconfig.json +4 -0
  84. package/templates/react-monorepo/template.json +47 -1
  85. package/templates/nest-api/files/src/common/controllers/metrics.controller.ts +0 -21
  86. package/templates/nest-api/files/src/common/interceptors/index.ts +0 -1
  87. package/templates/nest-api/files/src/common/interceptors/metrics.interceptor.ts +0 -37
  88. package/templates/nest-api/files/src/common/modules/metrics.module.ts +0 -28
@@ -0,0 +1,154 @@
1
+ """
2
+ OpenTelemetry: traces, logs and metrics over OTLP, built for OpenObserve.
3
+
4
+ Two ways to connect, in order of precedence:
5
+ - the standard OpenTelemetry variables (OTEL_EXPORTER_OTLP_ENDPOINT and
6
+ friends), which the exporters read themselves, for any OTLP backend;
7
+ - OPENOBSERVE_URL + OPENOBSERVE_ORG + OPENOBSERVE_TOKEN, from which the
8
+ OpenObserve endpoints and the org ingestion-token header are built.
9
+ With neither, setup_telemetry() does nothing and job_span() is a no-op.
10
+ """
11
+ import base64
12
+ import logging
13
+ import os
14
+ from contextlib import contextmanager
15
+ from dataclasses import dataclass, field
16
+
17
+ from opentelemetry import trace
18
+
19
+ DEFAULT_SERVICE_NAME = "acme-py"
20
+
21
+ _providers: list = []
22
+
23
+
24
+ @dataclass
25
+ class TelemetryConfig:
26
+ enabled: bool
27
+ service_name: str
28
+ environment: str
29
+ # Signal URLs; empty means the exporters resolve them from OTEL_* vars.
30
+ urls: dict = field(default_factory=dict)
31
+ headers: dict = field(default_factory=dict)
32
+
33
+
34
+ def resolve_telemetry(env=os.environ) -> TelemetryConfig:
35
+ service_name = env.get("OTEL_SERVICE_NAME") or DEFAULT_SERVICE_NAME
36
+ environment = env.get("APP_ENV") or "development"
37
+ off = TelemetryConfig(False, service_name, environment)
38
+
39
+ if env.get("OTEL_SDK_DISABLED") == "true":
40
+ return off
41
+ if env.get("OTEL_EXPORTER_OTLP_ENDPOINT"):
42
+ return TelemetryConfig(True, service_name, environment)
43
+
44
+ url, org, token = (
45
+ env.get("OPENOBSERVE_URL"),
46
+ env.get("OPENOBSERVE_ORG"),
47
+ env.get("OPENOBSERVE_TOKEN"),
48
+ )
49
+ if not (url and org and token):
50
+ return off
51
+
52
+ # OTLP/HTTP endpoints live under /api/<org>; an org ingestion token
53
+ # authenticates as `<org>:<token>`. Logs and traces land in the stream
54
+ # named here, one per service.
55
+ base = f"{url.rstrip('/')}/api/{org}"
56
+ credentials = base64.b64encode(f"{org}:{token}".encode()).decode()
57
+ return TelemetryConfig(
58
+ True,
59
+ service_name,
60
+ environment,
61
+ urls={
62
+ "traces": f"{base}/v1/traces",
63
+ "logs": f"{base}/v1/logs",
64
+ "metrics": f"{base}/v1/metrics",
65
+ },
66
+ headers={
67
+ "Authorization": f"Basic {credentials}",
68
+ "stream-name": env.get("OPENOBSERVE_STREAM") or service_name,
69
+ },
70
+ )
71
+
72
+
73
+ def setup_telemetry() -> bool:
74
+ """Start exporting, if connection settings are present. Call once, first."""
75
+ config = resolve_telemetry()
76
+ if not config.enabled:
77
+ return False
78
+
79
+ # Imported here so a worker without telemetry never loads the SDK.
80
+ from opentelemetry._logs import set_logger_provider
81
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
82
+ from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
83
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
84
+ from opentelemetry.instrumentation.redis import RedisInstrumentor
85
+ from opentelemetry.metrics import set_meter_provider
86
+ from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
87
+ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
88
+ from opentelemetry.sdk.metrics import MeterProvider
89
+ from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
90
+ from opentelemetry.sdk.resources import Resource
91
+ from opentelemetry.sdk.trace import TracerProvider
92
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
93
+
94
+ def exporter_args(signal: str) -> dict:
95
+ if not config.urls:
96
+ return {}
97
+ return {"endpoint": config.urls[signal], "headers": config.headers}
98
+
99
+ resource = Resource.create(
100
+ {
101
+ "service.name": config.service_name,
102
+ "deployment.environment.name": config.environment,
103
+ }
104
+ )
105
+
106
+ tracer_provider = TracerProvider(resource=resource)
107
+ tracer_provider.add_span_processor(
108
+ BatchSpanProcessor(OTLPSpanExporter(**exporter_args("traces")))
109
+ )
110
+ trace.set_tracer_provider(tracer_provider)
111
+
112
+ logger_provider = LoggerProvider(resource=resource)
113
+ logger_provider.add_log_record_processor(
114
+ BatchLogRecordProcessor(OTLPLogExporter(**exporter_args("logs")))
115
+ )
116
+ set_logger_provider(logger_provider)
117
+ # Every utils.logger.Logger propagates to the root logger.
118
+ logging.getLogger().addHandler(LoggingHandler(logger_provider=logger_provider))
119
+
120
+ meter_provider = MeterProvider(
121
+ resource=resource,
122
+ metric_readers=[
123
+ PeriodicExportingMetricReader(
124
+ OTLPMetricExporter(**exporter_args("metrics"))
125
+ )
126
+ ],
127
+ )
128
+ set_meter_provider(meter_provider)
129
+
130
+ RedisInstrumentor().instrument()
131
+ _providers.extend([tracer_provider, logger_provider, meter_provider])
132
+ return True
133
+
134
+
135
+ def shutdown_telemetry() -> None:
136
+ """Flush whatever is buffered. Safe to call when telemetry is off."""
137
+ for provider in _providers:
138
+ provider.shutdown()
139
+
140
+
141
+ @contextmanager
142
+ def job_span(queue_name: str, job):
143
+ """One span per job, so each job is its own trace in OpenObserve."""
144
+ tracer = trace.get_tracer("worker")
145
+ with tracer.start_as_current_span(
146
+ f"process {queue_name}",
147
+ attributes={"messaging.system": "bullmq", "messaging.destination.name": queue_name, "bullmq.job.id": str(job.id)},
148
+ ) as span:
149
+ try:
150
+ yield span
151
+ except Exception as error:
152
+ span.record_exception(error)
153
+ span.set_status(trace.Status(trace.StatusCode.ERROR, str(error)))
154
+ raise
@@ -0,0 +1,55 @@
1
+ from temporalio.client import Client, TLSConfig
2
+ from temporalio.worker import Worker
3
+
4
+ from config import CONFIG, TemporalTLS
5
+ from temporal.activities import greet
6
+ from temporal.workflows import Example
7
+
8
+
9
+ def temporal_tls(tls: TemporalTLS) -> TLSConfig | bool:
10
+ """
11
+ mTLS settings from PEM strings; none at all means plaintext (False).
12
+ Escaped \\n are unescaped, so a certificate fits on one line of an env file.
13
+ """
14
+ if not (tls.ca or tls.cert or tls.key):
15
+ return False
16
+ if not (tls.cert and tls.key):
17
+ raise ValueError("TEMPORAL_TLS_CERT and TEMPORAL_TLS_KEY must be set together")
18
+
19
+ def pem(value: str) -> bytes:
20
+ return value.replace("\\n", "\n").encode()
21
+
22
+ return TLSConfig(
23
+ server_root_ca_cert=pem(tls.ca) if tls.ca else None,
24
+ client_cert=pem(tls.cert),
25
+ client_private_key=pem(tls.key),
26
+ )
27
+
28
+
29
+ async def create_temporal_worker() -> Worker:
30
+ """
31
+ Connect to Temporal and build a worker for TEMPORAL_TASK_QUEUE, running the
32
+ workflows and activities in temporal/. Register new ones in the lists below.
33
+ """
34
+ settings = CONFIG.env.temporal
35
+ interceptors = []
36
+ # @feature:start telemetry
37
+ # A span per workflow and activity, continuing the trace of whatever
38
+ # started the workflow; a no-op while telemetry is off.
39
+ from temporalio.contrib.opentelemetry import TracingInterceptor
40
+
41
+ interceptors.append(TracingInterceptor())
42
+ # @feature:end
43
+
44
+ client = await Client.connect(
45
+ settings.address,
46
+ namespace=settings.namespace,
47
+ tls=temporal_tls(settings.tls),
48
+ interceptors=interceptors,
49
+ )
50
+ return Worker(
51
+ client,
52
+ task_queue=settings.task_queue,
53
+ workflows=[Example],
54
+ activities=[greet],
55
+ )
@@ -5,6 +5,7 @@ from urllib.parse import urlparse, quote, urlencode
5
5
  from config import CONFIG, Redis
6
6
  from utils.db import DB
7
7
  from utils.logger import Logger
8
+ from utils.telemetry import job_span # @feature telemetry
8
9
 
9
10
 
10
11
  def build_redis_url(options: Redis):
@@ -39,9 +40,19 @@ def create_worker(queue_name: str, process_function: Callable, concurrency: int
39
40
  An instance of BullMQ Worker.
40
41
  """
41
42
  connection = build_redis_url(CONFIG.env.redis)
43
+
44
+ processor = process_function
45
+ # @feature:start telemetry
46
+ async def traced(job: Job, token: str):
47
+ with job_span(queue_name, job):
48
+ return await process_function(job, token)
49
+
50
+ processor = traced
51
+ # @feature:end
52
+
42
53
  worker = Worker(
43
54
  queue_name,
44
- process_function,
55
+ processor,
45
56
  {
46
57
  "connection": connection,
47
58
  "autorun": True,
@@ -4,6 +4,8 @@ import asyncio
4
4
  import signal
5
5
  from utils.logger import Logger
6
6
  from utils.worker import create_worker
7
+ from utils.telemetry import setup_telemetry, shutdown_telemetry # @feature telemetry
8
+ from utils.temporal import create_temporal_worker # @feature temporal
7
9
 
8
10
  # import worker processes
9
11
  from workers.hello.process import hello_process
@@ -11,6 +13,7 @@ logger = Logger("worker")
11
13
 
12
14
 
13
15
  async def main():
16
+ setup_telemetry() # @feature telemetry
14
17
  logger.info("Starting Python 3.14 Worker...")
15
18
  shutdown_event = asyncio.Event()
16
19
  loop = asyncio.get_running_loop()
@@ -27,6 +30,12 @@ async def main():
27
30
 
28
31
  # Add workers
29
32
  hello = create_worker("hello", hello_process)
33
+ # @feature:start temporal
34
+ temporal_worker = await create_temporal_worker()
35
+ temporal_run = asyncio.create_task(temporal_worker.run())
36
+ # If the Temporal worker stops on its own (a fatal error), stop everything.
37
+ temporal_run.add_done_callback(lambda _: shutdown_event.set())
38
+ # @feature:end
30
39
 
31
40
  logger.info("Workers started and waiting for jobs.")
32
41
  try:
@@ -35,7 +44,14 @@ async def main():
35
44
  logger.info("Cleaning up workers...")
36
45
  # Close workers gracefully
37
46
  await hello.close()
47
+ # @feature:start temporal
48
+ # Stops polling and waits for running activities to finish.
49
+ if not temporal_run.done():
50
+ await temporal_worker.shutdown()
51
+ await temporal_run
52
+ # @feature:end
38
53
  logger.info("Workers shut down successfully.")
54
+ shutdown_telemetry() # @feature telemetry
39
55
 
40
56
  if __name__ == "__main__":
41
57
  asyncio.run(main())
@@ -55,6 +55,42 @@
55
55
  "files": [
56
56
  ".forgejo/**"
57
57
  ]
58
+ },
59
+ "telemetry": {
60
+ "title": "OpenTelemetry → OpenObserve",
61
+ "description": "Traces, logs and metrics over OTLP (set OPENOBSERVE_URL/ORG/TOKEN, or any OTEL_* backend), with a span per job and Redis instrumentation. Off at runtime until connected.",
62
+ "default": true,
63
+ "files": [
64
+ "utils/telemetry.py"
65
+ ],
66
+ "requirements": [
67
+ "googleapis-common-protos",
68
+ "opentelemetry-api",
69
+ "opentelemetry-exporter-http-transport",
70
+ "opentelemetry-exporter-otlp-common",
71
+ "opentelemetry-exporter-otlp-proto-common",
72
+ "opentelemetry-exporter-otlp-proto-http",
73
+ "opentelemetry-instrumentation",
74
+ "opentelemetry-instrumentation-redis",
75
+ "opentelemetry-proto",
76
+ "opentelemetry-sdk",
77
+ "opentelemetry-semantic-conventions",
78
+ "protobuf",
79
+ "wrapt"
80
+ ]
81
+ },
82
+ "temporal": {
83
+ "title": "Temporal worker",
84
+ "description": "Runs durable workflows and activities from temporal/ (an example of each included) on its own TEMPORAL_TASK_QUEUE, next to the BullMQ consumers, with a span per workflow and activity when telemetry is on. Pair with the API's temporal feature, which starts them.",
85
+ "files": [
86
+ "utils/temporal.py",
87
+ "temporal/**"
88
+ ],
89
+ "requirements": [
90
+ "nexus-rpc",
91
+ "temporalio",
92
+ "types-protobuf"
93
+ ]
58
94
  }
59
95
  },
60
96
  "hooks": [
@@ -52,7 +52,7 @@ done
52
52
 
53
53
  echo "==> flattening packages -> src/shared"
54
54
  mkdir -p "$DST/src/shared"
55
- for pkg in ui api auth layouts hooks types; do
55
+ for pkg in ui api auth layouts hooks types telemetry; do
56
56
  cp -r "$SRC/packages/$pkg/src" "$DST/src/shared/$pkg"
57
57
  done
58
58
  # The brand tokens live in the config package in the monorepo because three
@@ -94,6 +94,7 @@ find "$DST/src" -type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.css' \) -pr
94
94
  -e "s#@acme/auth#@/shared/auth#g" \
95
95
  -e "s#@acme/layouts#@/shared/layouts#g" \
96
96
  -e "s#@acme/hooks#@/shared/hooks#g" \
97
+ -e "s#@acme/telemetry#@/shared/telemetry#g" \
97
98
  -e "s#@acme/types#@/shared/types#g"
98
99
 
99
100
  # The auth screens moved one level deeper than their components and lib, so
@@ -11,6 +11,17 @@ PORT='3000'
11
11
  # Needed when you reach the dev server through a tunnel or a wildcard DNS name.
12
12
  VITE_APP_ALLOWED_HOSTS=''
13
13
 
14
+ # @feature:start telemetry
15
+ # OpenObserve browser monitoring (RUM): page views, errors, slow requests, and
16
+ # trace headers on API calls. Leave empty and it stays off. The client token is
17
+ # a RUM token (OpenObserve → Ingestion → RUM): write-only and meant to ship in
18
+ # the bundle — never the backend's ingestion token.
19
+ # The instance must list this app's origin in ZO_CORS_ALLOWED_ORIGINS.
20
+ VITE_OPENOBSERVE_URL='https://o2.aurostack.co'
21
+ VITE_OPENOBSERVE_ORG=''
22
+ VITE_OPENOBSERVE_CLIENT_TOKEN=''
23
+ # @feature:end
24
+
14
25
  # @feature:start marketing
15
26
  # The canonical public origin — the ONE origin that may be indexed. Must match
16
27
  # PRODUCTION_ORIGIN in src/features/seo/routes.ts. Anything else (previews,
@@ -26,6 +26,8 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@fontsource-variable/inter": "^5.3.0",
29
+ "@openobserve/browser-logs": "^0.4.3",
30
+ "@openobserve/browser-rum": "^0.4.3",
29
31
  "@radix-ui/react-label": "^2.1.7",
30
32
  "@radix-ui/react-popover": "^1.1.21",
31
33
  "@radix-ui/react-select": "^2.3.4",
@@ -26,3 +26,13 @@ export const APP_ORIGIN = window.location.origin;
26
26
  */
27
27
  export const SITE_URL = (env.VITE_APP_SITE_URL as string | undefined) ?? '';
28
28
  // @feature:end
29
+
30
+ // @feature:start telemetry
31
+ /** OpenObserve browser monitoring (see @/shared/telemetry); unset keeps it off. */
32
+ export const TELEMETRY = {
33
+ url: env.VITE_OPENOBSERVE_URL as string | undefined,
34
+ org: env.VITE_OPENOBSERVE_ORG as string | undefined,
35
+ clientToken: env.VITE_OPENOBSERVE_CLIENT_TOKEN as string | undefined,
36
+ environment: env.MODE
37
+ };
38
+ // @feature:end
@@ -1,5 +1,7 @@
1
1
  import { StrictMode } from 'react';
2
2
  import { createRoot } from 'react-dom/client';
3
+ import { initTelemetry } from '@/shared/telemetry'; // @feature telemetry
4
+ import { API_URL, TELEMETRY } from '@/lib/env'; // @feature telemetry
3
5
  import { RouterProvider } from 'react-router';
4
6
  import '@/shared/ui/styles/globals.css';
5
7
  import { Providers } from './app/providers';
@@ -10,6 +12,8 @@ import { router } from './app/router';
10
12
  // evaluating and `providers` has already called `configureApi` at module scope,
11
13
  // so the fetch races the session check instead of queueing behind it.
12
14
 
15
+ initTelemetry({ service: 'acme-app', ...TELEMETRY, apiUrls: [API_URL] }); // @feature telemetry
16
+
13
17
  createRoot(document.getElementById('root')!).render(
14
18
  <StrictMode>
15
19
  <Providers>
@@ -3,6 +3,7 @@ import { useDispatch } from 'react-redux';
3
3
  import { getAuthClient } from './client';
4
4
  import { sessionCleared, sessionLoaded, sessionLoading, type AuthUser } from './slice';
5
5
  import { AUTH_BROADCAST_CHANNEL } from './logout';
6
+ import { setTelemetryUser } from '@/shared/telemetry'; // @feature telemetry
6
7
 
7
8
  function toAuthUser(user: Record<string, unknown> | null | undefined): AuthUser | null {
8
9
  if (!user) return null;
@@ -33,6 +34,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
33
34
  }
34
35
  const user = (session.data?.user as Record<string, unknown> | undefined) ?? null;
35
36
  dispatch(sessionLoaded(toAuthUser(user)));
37
+ setTelemetryUser(user ? { id: String(user.id) } : null); // @feature telemetry
36
38
  }, [session.isPending, session.data, dispatch]);
37
39
 
38
40
  useEffect(() => {
@@ -1,6 +1,7 @@
1
1
  import { Component, type ErrorInfo, type ReactNode } from 'react';
2
2
  import { Button } from '@/shared/ui';
3
3
  import { StateScreen } from './states/state-screen';
4
+ import { reportError } from '@/shared/telemetry'; // @feature telemetry
4
5
 
5
6
  type ErrorBoundaryProps = {
6
7
  children: ReactNode;
@@ -21,6 +22,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
21
22
 
22
23
  componentDidCatch(error: Error, info: ErrorInfo): void {
23
24
  console.error('ErrorBoundary caught an error', error, info);
25
+ reportError(error, { componentStack: info.componentStack }); // @feature telemetry
24
26
  }
25
27
 
26
28
  render(): ReactNode {
@@ -0,0 +1,80 @@
1
+ // @/shared/telemetry — browser monitoring (RUM) and logs, sent to OpenObserve.
2
+ //
3
+ // Page views, errors, slow resources and user actions, plus console errors as
4
+ // logs. Requests to the app's own API carry a W3C `traceparent` header, so a
5
+ // click links to the backend trace it caused. The SDK finishes starting a
6
+ // moment after init (its session store is async), so the first request or two
7
+ // of a page load (typically the session check) go out without the header.
8
+ // Off until connection settings are passed; everything here is then a no-op.
9
+ import { DefaultPrivacyLevel, openobserveRum } from '@openobserve/browser-rum';
10
+ import { openobserveLogs } from '@openobserve/browser-logs';
11
+
12
+ export interface TelemetryOptions {
13
+ /** Service name, e.g. `acme-client` — one per app. */
14
+ service: string;
15
+ /** OpenObserve base URL, e.g. `https://o2.example.com`. */
16
+ url?: string;
17
+ /** The project's organization identifier. */
18
+ org?: string;
19
+ /**
20
+ * A RUM client token (Ingestion → RUM). Write-only and built for shipping
21
+ * in a bundle — never the backend's ingestion token.
22
+ */
23
+ clientToken?: string;
24
+ /** `development`, `production`, … — Vite's `import.meta.env.MODE`. */
25
+ environment?: string;
26
+ version?: string;
27
+ /** Backend origin(s) whose requests should carry trace headers. */
28
+ apiUrls?: string[];
29
+ /** Share of sessions to record for replay, 0–100. Off by default. */
30
+ sessionReplaySampleRate?: number;
31
+ }
32
+
33
+ let started = false;
34
+
35
+ export function initTelemetry(options: TelemetryOptions): boolean {
36
+ const { url, org, clientToken } = options;
37
+ if (started || !url || !org || !clientToken) return false;
38
+
39
+ // The SDK takes a bare host (no scheme) plus an explicit plain-HTTP flag.
40
+ const { host, protocol } = new URL(url);
41
+ const common = {
42
+ clientToken,
43
+ site: host,
44
+ organizationIdentifier: org,
45
+ insecureHTTP: protocol === 'http:',
46
+ service: options.service,
47
+ env: options.environment,
48
+ version: options.version
49
+ };
50
+
51
+ openobserveRum.init({
52
+ ...common,
53
+ applicationId: options.service,
54
+ sessionSampleRate: 100,
55
+ sessionReplaySampleRate: options.sessionReplaySampleRate ?? 0,
56
+ defaultPrivacyLevel: DefaultPrivacyLevel.MASK_USER_INPUT,
57
+ trackResources: true,
58
+ trackLongTasks: true,
59
+ trackUserInteractions: true,
60
+ allowedTracingUrls: (options.apiUrls ?? []).filter(Boolean).map((match) => ({
61
+ match,
62
+ propagatorTypes: ['tracecontext' as const]
63
+ }))
64
+ });
65
+ openobserveLogs.init({ ...common, forwardErrorsToLogs: true });
66
+ started = true;
67
+ return true;
68
+ }
69
+
70
+ /** Report a handled error — e.g. from an error boundary. */
71
+ export function reportError(error: unknown, context?: object): void {
72
+ if (started) openobserveRum.addError(error, context);
73
+ }
74
+
75
+ /** Tie the session to a signed-in user (id only), or clear it on logout. */
76
+ export function setTelemetryUser(user: { id: string } | null): void {
77
+ if (!started) return;
78
+ if (user) openobserveRum.setUser({ id: user.id });
79
+ else openobserveRum.clearUser();
80
+ }
@@ -189,6 +189,20 @@
189
189
  "secrets"
190
190
  ]
191
191
  }
192
+ },
193
+ "telemetry": {
194
+ "title": "Browser monitoring → OpenObserve",
195
+ "description": "OpenObserve RUM and logs: page views, errors (including the error boundary's), slow resources, user actions, the signed-in user id, and traceparent headers on API calls so frontend and backend traces link up. Session replay is off unless a project turns it on. Off at runtime until VITE_OPENOBSERVE_* are set.",
196
+ "default": true,
197
+ "files": [
198
+ "src/shared/telemetry/**"
199
+ ],
200
+ "packageJson": {
201
+ "dependencies": [
202
+ "@openobserve/browser-logs",
203
+ "@openobserve/browser-rum"
204
+ ]
205
+ }
192
206
  }
193
207
  },
194
208
  "hooks": [
@@ -199,7 +213,7 @@
199
213
  },
200
214
  {
201
215
  "title": "yarn install",
202
- "run": "yarn install",
216
+ "run": "yarn install --no-immutable",
203
217
  "optional": true
204
218
  },
205
219
  {
@@ -14,3 +14,14 @@ VITE_APP_ADMIN_HOST='http://localhost:3003'
14
14
 
15
15
  # This app's own dev port.
16
16
  PORT='3003'
17
+
18
+ # @feature:start telemetry
19
+ # OpenObserve browser monitoring (RUM): page views, errors, slow requests, and
20
+ # trace headers on API calls. Leave empty and it stays off. The client token is
21
+ # a RUM token (OpenObserve → Ingestion → RUM): write-only and meant to ship in
22
+ # the bundle — never the backend's ingestion token.
23
+ # The instance must list this app's origin in ZO_CORS_ALLOWED_ORIGINS.
24
+ VITE_OPENOBSERVE_URL='https://o2.aurostack.co'
25
+ VITE_OPENOBSERVE_ORG=''
26
+ VITE_OPENOBSERVE_CLIENT_TOKEN=''
27
+ # @feature:end
@@ -15,6 +15,7 @@
15
15
  "@acme/api": "workspace:*",
16
16
  "@acme/auth": "workspace:*",
17
17
  "@acme/layouts": "workspace:*",
18
+ "@acme/telemetry": "workspace:*",
18
19
  "@acme/ui": "workspace:*",
19
20
  "@reduxjs/toolkit": "^2.12.0",
20
21
  "lucide-react": "^1.17.0",
@@ -10,3 +10,13 @@ export const ADMIN_URL = env.VITE_APP_ADMIN_HOST as string;
10
10
  export const CLIENT_URL = env.VITE_APP_CLIENT_HOST as string;
11
11
  /** The public marketing site (logout destination). */
12
12
  export const LANDING_URL = env.VITE_APP_LANDING as string;
13
+
14
+ // @feature:start telemetry
15
+ /** OpenObserve browser monitoring (see @acme/telemetry); unset keeps it off. */
16
+ export const TELEMETRY = {
17
+ url: env.VITE_OPENOBSERVE_URL as string | undefined,
18
+ org: env.VITE_OPENOBSERVE_ORG as string | undefined,
19
+ clientToken: env.VITE_OPENOBSERVE_CLIENT_TOKEN as string | undefined,
20
+ environment: env.MODE
21
+ };
22
+ // @feature:end
@@ -1,10 +1,14 @@
1
1
  import { StrictMode } from 'react';
2
2
  import { createRoot } from 'react-dom/client';
3
+ import { initTelemetry } from '@acme/telemetry'; // @feature telemetry
4
+ import { API_URL, TELEMETRY } from './lib/env'; // @feature telemetry
3
5
  import { RouterProvider } from 'react-router';
4
6
  import '@acme/ui/globals.css';
5
7
  import { Providers } from './app/providers';
6
8
  import { router } from './app/router';
7
9
 
10
+ initTelemetry({ service: 'acme-admin', ...TELEMETRY, apiUrls: [API_URL] }); // @feature telemetry
11
+
8
12
  createRoot(document.getElementById('root')!).render(
9
13
  <StrictMode>
10
14
  <Providers>
@@ -14,3 +14,14 @@ VITE_APP_ADMIN_HOST='http://localhost:3003'
14
14
 
15
15
  # This app's own dev port.
16
16
  PORT='3001'
17
+
18
+ # @feature:start telemetry
19
+ # OpenObserve browser monitoring (RUM): page views, errors, slow requests, and
20
+ # trace headers on API calls. Leave empty and it stays off. The client token is
21
+ # a RUM token (OpenObserve → Ingestion → RUM): write-only and meant to ship in
22
+ # the bundle — never the backend's ingestion token.
23
+ # The instance must list this app's origin in ZO_CORS_ALLOWED_ORIGINS.
24
+ VITE_OPENOBSERVE_URL='https://o2.aurostack.co'
25
+ VITE_OPENOBSERVE_ORG=''
26
+ VITE_OPENOBSERVE_CLIENT_TOKEN=''
27
+ # @feature:end
@@ -15,6 +15,7 @@
15
15
  "@acme/api": "workspace:*",
16
16
  "@acme/auth": "workspace:*",
17
17
  "@acme/layouts": "workspace:*",
18
+ "@acme/telemetry": "workspace:*",
18
19
  "@acme/ui": "workspace:*",
19
20
  "@reduxjs/toolkit": "^2.12.0",
20
21
  "formik": "^2.4.9",
@@ -6,3 +6,13 @@ export const API_URL = env.VITE_APP_API_URL;
6
6
  /** Terms and Privacy live on the marketing site, which is a different origin. */
7
7
  export const LANDING_URL = env.VITE_APP_LANDING;
8
8
  export const AUTH_URL = window.location.origin;
9
+
10
+ // @feature:start telemetry
11
+ /** OpenObserve browser monitoring (see @acme/telemetry); unset keeps it off. */
12
+ export const TELEMETRY = {
13
+ url: env.VITE_OPENOBSERVE_URL as string | undefined,
14
+ org: env.VITE_OPENOBSERVE_ORG as string | undefined,
15
+ clientToken: env.VITE_OPENOBSERVE_CLIENT_TOKEN as string | undefined,
16
+ environment: env.MODE
17
+ };
18
+ // @feature:end
@@ -1,10 +1,14 @@
1
1
  import { StrictMode } from 'react';
2
2
  import { createRoot } from 'react-dom/client';
3
+ import { initTelemetry } from '@acme/telemetry'; // @feature telemetry
4
+ import { API_URL, TELEMETRY } from './lib/env'; // @feature telemetry
3
5
  import { RouterProvider } from 'react-router';
4
6
  import '@acme/ui/globals.css';
5
7
  import { Providers } from './app/providers';
6
8
  import { router } from './app/router';
7
9
 
10
+ initTelemetry({ service: 'acme-auth', ...TELEMETRY, apiUrls: [API_URL] }); // @feature telemetry
11
+
8
12
  createRoot(document.getElementById('root')!).render(
9
13
  <StrictMode>
10
14
  <Providers>