diff --git a/contrib/container/Caddyfile b/contrib/container/Caddyfile index 6c8d0c973a..71a6a0efed 100644 --- a/contrib/container/Caddyfile +++ b/contrib/container/Caddyfile @@ -20,7 +20,7 @@ header Allow GET,HEAD,OPTIONS header Access-Control-Allow-Origin * header Access-Control-Allow-Methods GET,HEAD,OPTIONS - header Access-Control-Allow-Headers Authorization,Content-Type,User-Agent + header Access-Control-Allow-Headers Authorization,Content-Type,User-Agent,traceparent @cors_preflight{args[0]} method OPTIONS diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index b9a465f21a..0368310707 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 452 +INVENTREE_API_VERSION = 453 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v453 -> 2026-02-11 : https://github.com/inventree/InvenTree/pull/11244 + - Adds (internal) endpoint to end a observability tooling session + v452 -> 2026-02-10 : https://github.com/inventree/InvenTree/pull/11276 - Adds "install_into_detail" field to the BuildItem API endpoint diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index ffd17fcdb1..533ea28f0d 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -1289,10 +1289,12 @@ CORS_ALLOWED_ORIGIN_REGEXES = get_setting( typecast=list, ) +_allowed_headers = (*default_cors_headers, 'traceparent') # Allow extra CORS headers in DEBUG mode # Required for serving /static/ and /media/ files if DEBUG: - CORS_ALLOW_HEADERS = (*default_cors_headers, 'cache-control', 'pragma', 'expires') + _allowed_headers = (*_allowed_headers, 'cache-control', 'pragma', 'expires') +CORS_ALLOW_HEADERS = _allowed_headers # In debug mode allow CORS requests from localhost # This allows connection from the frontend development server diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 694d28940b..5f53fcf3b0 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -22,6 +22,7 @@ from django_q.tasks import async_task from djmoney.contrib.exchange.models import ExchangeBackend, Rate from drf_spectacular.utils import OpenApiResponse, extend_schema from error_report.models import Error +from opentelemetry import trace from pint._typing import UnitLike from rest_framework import generics, serializers from rest_framework.exceptions import NotAcceptable, NotFound, PermissionDenied @@ -1115,6 +1116,50 @@ class HealthCheckView(APIView): ) +class ObservabilityEndSerializer(serializers.Serializer): + """Serializer for observability end endpoint.""" + + traceid = serializers.CharField( + help_text='Trace ID to end', max_length=128, required=True + ) + service = serializers.CharField( + help_text='Service name', max_length=128, required=True + ) + + +class ObservabilityEnd(CreateAPI): + """Endpoint for observability tools.""" + + permission_classes = [AllowAnyOrReadScope] + serializer_class = ObservabilityEndSerializer + + def create(self, request, *args, **kwargs): + """End a trace in the observability system.""" + if not settings.TRACING_ENABLED: + return Response({'status': 'ok'}) + + data = self.get_serializer(data=request.data) + data.is_valid(raise_exception=True) + + traceid = data.validated_data['traceid'] + # service = data.validated_data['service'] # This will become interesting with frontend observability + + # End the foreign trend via the low level otel API + tracer = trace.get_tracer(__name__) + span_context = trace.SpanContext( + trace_id=int(traceid, 16), + span_id=0, + is_remote=True, + trace_flags=trace.TraceFlags(0x01), + trace_state=trace.TraceState(), + ) + with tracer.start_span('Ending session') as span: + span.add_event('Ending external trace') + span.add_link(span_context) + + return Response({'status': 'ok'}) + + selection_urls = [ path( '/', @@ -1393,7 +1438,22 @@ common_api_urls = [ # System APIs (related to basic system functions) path( 'system/', - include([path('health/', HealthCheckView.as_view(), name='api-system-health')]), + include([ + # Health check + path('health/', HealthCheckView.as_view(), name='api-system-health') + ]), + ), + # Internal System APIs - DO NOT USE + path( + 'system-internal/', + include([ + # Observability + path( + 'observability/end', + ObservabilityEnd.as_view(), + name='api-system-observability', + ) + ]), ), ] diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index 8f7d905d36..e7adf3f0b8 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -244,5 +244,8 @@ export enum ApiEndpoints { email_test = 'admin/email/test/', config_list = 'admin/config/', parameter_list = 'parameter/', - parameter_template_list = 'parameter/template/' + parameter_template_list = 'parameter/template/', + + // Internal system things + system_internal_trace_end = 'system-internal/observability/end' } diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index cbaae4965a..96e50c813e 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,6 +1,8 @@ import { QueryClient } from '@tanstack/react-query'; import axios from 'axios'; +import { ApiEndpoints, apiUrl } from '@lib/index'; +import { frontendID, serviceName } from './defaults/defaults'; import { useLocalState } from './states/LocalState'; // Global API instance @@ -33,3 +35,22 @@ export const queryClient = new QueryClient({ } } }); +export function setTraceId() { + const runID = crypto.randomUUID().replace(/-/g, ''); + const traceid = `00-${runID}-${frontendID}-01`; + api.defaults.headers['traceparent'] = traceid; + + return runID; +} +export function removeTraceId(traceid: string) { + delete api.defaults.headers['traceparent']; + + api + .post(apiUrl(ApiEndpoints.system_internal_trace_end), { + traceid: traceid, + service: serviceName + }) + .catch((error) => { + console.error('Error removing trace ID:', error); + }); +} diff --git a/src/frontend/src/defaults/defaults.tsx b/src/frontend/src/defaults/defaults.tsx index fc91644c1b..b3aa22bca1 100644 --- a/src/frontend/src/defaults/defaults.tsx +++ b/src/frontend/src/defaults/defaults.tsx @@ -36,3 +36,6 @@ export const SizeMarks: SiteMarkProps[] = [ { value: 75, label: 'lg' }, { value: 100, label: 'xl' } ]; + +export const frontendID = '706f6f7062757474'; +export const serviceName = 'FRONTEND'; diff --git a/src/frontend/src/pages/Auth/Login.tsx b/src/frontend/src/pages/Auth/Login.tsx index 65abe4769a..f5510d5704 100644 --- a/src/frontend/src/pages/Auth/Login.tsx +++ b/src/frontend/src/pages/Auth/Login.tsx @@ -6,7 +6,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { useShallow } from 'zustand/react/shallow'; -import { setApiDefaults } from '../../App'; +import { removeTraceId, setApiDefaults, setTraceId } from '../../App'; import { AuthFormOptions } from '../../components/forms/AuthFormOptions'; import { AuthenticationForm } from '../../components/forms/AuthenticationForm'; import { InstanceOptions } from '../../components/forms/InstanceOptions'; @@ -63,7 +63,9 @@ export default function Login() { if (newHost === null) return; setHost(hostList[newHost]?.host, newHost); setApiDefaults(); + const traceid = setTraceId(); fetchServerApiState(); + removeTraceId(traceid); } // Set default host to localhost if no host is selected diff --git a/src/frontend/src/states/states.tsx b/src/frontend/src/states/states.tsx index 42b5f23b1d..4e9a1cfe33 100644 --- a/src/frontend/src/states/states.tsx +++ b/src/frontend/src/states/states.tsx @@ -1,5 +1,5 @@ import type { PluginProps } from '@lib/types/Plugins'; -import { setApiDefaults } from '../App'; +import { removeTraceId, setApiDefaults, setTraceId } from '../App'; import { useGlobalStatusState } from './GlobalStatusState'; import { useIconState } from './IconState'; import { useServerApiState } from './ServerApiState'; @@ -53,6 +53,7 @@ export async function fetchGlobalStates() { } setApiDefaults(); + const traceId = setTraceId(); await Promise.all([ useServerApiState.getState().fetchServerApiState(), useUserSettingsState.getState().fetchSettings(), @@ -60,4 +61,5 @@ export async function fetchGlobalStates() { useGlobalStatusState.getState().fetchStatus(), useIconState.getState().fetchIcons() ]); + removeTraceId(traceId); }