From a372ce6e68759fbb61004690dd89ab8c2327c364 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gys=20Borges=20da=20Silveira?= Date: Sat, 11 Jul 2026 10:34:11 -0300 Subject: [PATCH 1/6] feat(telemetry): add native telemetry hooks and multi-instance lifecycle isolation --- doc/roadmap/README.md | 11 +- doc/roadmap/prioritization_matrix.md | 2 +- doc/telemetry.md | 76 ++++++ doc/telemetry.pt-BR.md | 76 ++++++ src/Horse.Core.Router.Radix.pas | 15 +- src/Horse.Core.RouterTree.pas | 13 +- src/Horse.Core.pas | 236 ++++++++++++++--- src/Horse.Instance.pas | 32 +++ src/Horse.pas | 13 + tests/src/Console.dpr | 1 + .../src/tests/Tests.Integration.Telemetry.pas | 248 ++++++++++++++++++ 11 files changed, 681 insertions(+), 42 deletions(-) create mode 100644 tests/src/tests/Tests.Integration.Telemetry.pas diff --git a/doc/roadmap/README.md b/doc/roadmap/README.md index 463e8d25..73ed6456 100644 --- a/doc/roadmap/README.md +++ b/doc/roadmap/README.md @@ -8,12 +8,7 @@ Este documento detalha o planejamento de melhorias arquiteturais de longo prazo ## 🗺️ Roadmap de Evolução Arquitetural (Pendente) -### 6. Ganchos de Telemetria Padronizados (Observabilidade / OpenTelemetry) -* **Descrição:** Disponibilizar ganchos internos no Core para extração de latência, volumetria de requests e status HTTP sem perdas de performance. -* **Ganhos:** - * Integração nativa facilitada com coletores de métricas do ecossistema APM (como Prometheus e Jaeger). - -### 7. Roteamento Avançado (Regex e Parâmetros Opcionais) +### 6. Roteamento Avançado (Regex e Parâmetros Opcionais) * **Descrição:** Permitir parâmetros opcionais (`/users/:id?`) e restrições de rotas baseadas em Expressões Regulares (`/users/:id(\d+)`) na árvore do Radix Router. @@ -71,6 +66,10 @@ Este documento detalha o planejamento de melhorias arquiteturais de longo prazo * **Status:** 🟢 **Concluído e Liberado como Middleware** * **Implementação:** Desenvolvido o middleware oficial [horse-dto](https://github.com/regyssilveira/horse-dto) para realizar a desserialização automática de payloads de requisições (JSON, Query params, Route params e Form fields) diretamente para classes DTO em Delphi e Lazarus, executando validações declarativas robustas baseadas em atributos customizados (como `[Required]`, `[Email]`, `[Range]`, `[CustomValidator]`) antes que a requisição seja entregue aos controllers lógicos da aplicação, eliminando código repetitivo (boilerplate) de forma isolada e elegante. +### 14. Ganchos de Telemetria Padronizados (Observabilidade Nativa) +* **Status:** 🟢 **Concluído e Liberado** +* **Implementação:** Disponibilizada a infraestrutura nativa e de baixíssimo overhead (`THorse.AddOnTelemetry` e `LInstance.AddOnTelemetry`) para interceptação automática e medição de latência baseada em `TStopwatch` (stack-allocated / zero-allocation). Totalmente integrado de forma fail-safe ao pipeline de roteamento (`Radix` e `Tree`), provendo suporte polimórfico a ganchos isolados no Multi-Instance e mantendo 100% de retrocompatibilidade com o ecossistema de middlewares. + --- ## ✅ Entregas Recentes de Testes & CI/CD (Concluído) diff --git a/doc/roadmap/prioritization_matrix.md b/doc/roadmap/prioritization_matrix.md index 154df72d..94e476a4 100644 --- a/doc/roadmap/prioritization_matrix.md +++ b/doc/roadmap/prioritization_matrix.md @@ -24,7 +24,7 @@ Esta tabela classifica as 13 melhorias pendentes do roadmap técnico do Horse co | 1 | **Refatoração Multi-Instance** | Arquitetura | 5 | 4 | **1.25** | ⚙️ **Transparente** (Mantém retrocompatibilidade) | 🟢 **Concluído** (Implementado e Liberado) | | 6 | **DTO Auto-Binding & Validação** | DX / Produtividade | 5 | 4 | **1.25** | ➕ **Novo Middleware** (Opcional) | 🟢 **Concluído** (Implementado e Liberado como Middleware) | | 2 | **Pool de Buffers (MemoryBufferPool)** | Otimização | 4 | 4 | **1.00** | ⚙️ **Transparente** (Performance por baixo dos panos) | 🟢 **Concluído** (Implementado e Liberado) | -| 7 | **Ganchos OpenTelemetry/APM** | Observabilidade | 3 | 3 | **1.00** | ⚙️ **Transparente / Opcional** | ⏳ **Segunda prioridade** | +| 7 | **Ganchos OpenTelemetry/APM** | Observabilidade | 3 | 3 | **1.00** | ⚙️ **Transparente / Opcional** | 🟢 **Concluído** (Implementado e Liberado) | | 8 | **Roteamento Regex e Opcionais** | Recursos | 4 | 5 | **0.80** | ➕ **Novo Recurso** (Opcional) | 🔬 **Alta Complexidade** (Exige reescrever árvore Radix) | --- diff --git a/doc/telemetry.md b/doc/telemetry.md index 09116675..380f4ddf 100644 --- a/doc/telemetry.md +++ b/doc/telemetry.md @@ -81,9 +81,85 @@ begin // Metrics scraper endpoint on a private port // Note: Running multiple Horse instances is supported. +end; +``` + +--- + +## Native Telemetry Hooks + +Horse introduces a native telemetry hook of extremely high precision and *Zero-Allocation*, based on `TStopwatch` (stack-allocated). + +This feature allows you to monitor and measure the latency of all processed HTTP requests with millisecond precision, enabling easy integration with logging, APM (Application Performance Monitoring) tools, and custom observability collectors. + +### Callback Signature + +The telemetry callback type is defined as follows: + +```delphi +THorseOnTelemetry = {$IF DEFINED(FPC)}procedure{$ELSE}reference to procedure{$ENDIF}(const ARequest: THorseRequest; const AResponse: THorseResponse; const AExecutionTimeMS: Double); +``` + +### Setting Up the Hook Globally + +You can register a global callback that will be triggered at the end of all HTTP requests in the application: + +```delphi +uses + Horse, System.SysUtils; + +begin + THorse.AddOnTelemetry( + procedure(const Req: THorseRequest; const Res: THorseResponse; const ExecutionTimeMS: Double) + begin + Writeln(Format('[Telemetry] %s %s - Status: %d - Latency: %.2f ms', + [Req.Method, Req.PathInfo, Res.Status, ExecutionTimeMS])); + end); + + THorse.Get('/ping', + procedure(Req: THorseRequest; Res: THorseResponse) + begin + Res.Send('pong'); + end); + + THorse.Listen(9000); +end. +``` + +### Instance Isolation (Multi-Instance) + +If your application uses the Multi-Instance architecture (`THorseInstance`), you can register telemetry hooks isolated by port/instance. Horse polymorphically resolves the correct instance associated with the active request: + +```delphi +uses + Horse, System.SysUtils; + +var + LInstance1, LInstance2: THorseInstance; +begin + LInstance1 := THorseInstance.Create; + LInstance1.AddOnTelemetry( + procedure(const Req: THorseRequest; const Res: THorseResponse; const ExecutionTimeMS: Double) + begin + Writeln(Format('[Instance 1 - Port %d] Latency: %.2f ms', [Req.RawWebRequest.ServerPort, ExecutionTimeMS])); + end); + LInstance1.Get('/service1', ...); + + LInstance2 := THorseInstance.Create; + LInstance2.AddOnTelemetry( + procedure(const Req: THorseRequest; const Res: THorseResponse; const ExecutionTimeMS: Double) + begin + Writeln(Format('[Instance 2 - Port %d] Latency: %.2f ms', [Req.RawWebRequest.ServerPort, ExecutionTimeMS])); + end); + LInstance2.Get('/service2', ...); end. ``` +### Performance Guarantee + +* **Zero-Allocation:** Time tracking utilizes `TStopwatch` allocated directly on the thread stack, generating no pressure on the Garbage Collector (FPC/Lazarus) or memory heap allocation stress in Delphi. +* **Security & Isolation:** The telemetry hook is triggered synchronously within the `finally` block of the physical routing, ensuring that the total time captures middlewares, route processing, and any error generated in the pipeline. + --- ## See Also diff --git a/doc/telemetry.pt-BR.md b/doc/telemetry.pt-BR.md index f1b9efd5..c7b4f790 100644 --- a/doc/telemetry.pt-BR.md +++ b/doc/telemetry.pt-BR.md @@ -86,6 +86,82 @@ end. --- +## Ganchos de Telemetria Nativos (Native Telemetry Hooks) + +O Horse introduz um gancho de telemetria nativo de altíssima precisão e sem alocação de memória (*Zero-Allocation*), baseado em `TStopwatch` (stack-allocated). + +Este recurso permite monitorar e medir com precisão milimétrica a latência de todas as requisições HTTP processadas, permitindo a fácil integração de logs, ferramentas APM (Application Performance Monitoring) e coletores customizados de observabilidade. + +### Assinatura do Callback + +O tipo do callback de telemetria é definido da seguinte forma: + +```delphi +THorseOnTelemetry = {$IF DEFINED(FPC)}procedure{$ELSE}reference to procedure{$ENDIF}(const ARequest: THorseRequest; const AResponse: THorseResponse; const AExecutionTimeMS: Double); +``` + +### Configurando o Gancho Globalmente + +Você pode registrar um callback global que será disparado ao término de todas as requisições HTTP da aplicação: + +```delphi +uses + Horse, System.SysUtils; + +begin + THorse.AddOnTelemetry( + procedure(const Req: THorseRequest; const Res: THorseResponse; const ExecutionTimeMS: Double) + begin + Writeln(Format('[Telemetry] %s %s - Status: %d - Latency: %.2f ms', + [Req.Method, Req.PathInfo, Res.Status, ExecutionTimeMS])); + end); + + THorse.Get('/ping', + procedure(Req: THorseRequest; Res: THorseResponse) + begin + Res.Send('pong'); + end); + + THorse.Listen(9000); +end. +``` + +### Isolamento por Instância (Multi-Instance) + +Se sua aplicação utiliza a arquitetura Multi-Instance (`THorseInstance`), você pode registrar ganchos de telemetria isolados por porta/instância. O Horse resolve polimorficamente a instância correta associada à requisição ativa: + +```delphi +uses + Horse, System.SysUtils; + +var + LInstance1, LInstance2: THorseInstance; +begin + LInstance1 := THorseInstance.Create; + LInstance1.AddOnTelemetry( + procedure(const Req: THorseRequest; const Res: THorseResponse; const ExecutionTimeMS: Double) + begin + Writeln(Format('[Instance 1 - Port %d] Latency: %.2f ms', [Req.RawWebRequest.ServerPort, ExecutionTimeMS])); + end); + LInstance1.Get('/service1', ...); + + LInstance2 := THorseInstance.Create; + LInstance2.AddOnTelemetry( + procedure(const Req: THorseRequest; const Res: THorseResponse; const ExecutionTimeMS: Double) + begin + Writeln(Format('[Instance 2 - Port %d] Latency: %.2f ms', [Req.RawWebRequest.ServerPort, ExecutionTimeMS])); + end); + LInstance2.Get('/service2', ...); +end. +``` + +### Garantia de Performance + +* **Zero-Allocation:** O controle de tempo utiliza `TStopwatch` alocado diretamente na stack da thread, não gerando pressão no Garbage Collector (FPC/Lazarus) ou estresse de heap/alocação de memória no Delphi. +* **Segurança e Isolamento:** O gancho de telemetria é acionado de forma síncrona dentro da seção `finally` do roteamento físico, garantindo que o tempo total capture middlewares, processamento da rota e qualquer erro gerado no pipeline. + +--- + ## Veja Também - [Ecossistema de Middlewares](./middleware-ecosystem.pt-BR.md) - [Criando um Middleware](./writing-middleware.pt-BR.md) diff --git a/src/Horse.Core.Router.Radix.pas b/src/Horse.Core.Router.Radix.pas index 6b80ca72..2c2dd8ae 100644 --- a/src/Horse.Core.Router.Radix.pas +++ b/src/Horse.Core.Router.Radix.pas @@ -95,11 +95,11 @@ implementation uses {$IF DEFINED(FPC)} - Classes, + Classes, Diagnostics, {$ELSE} - System.SysUtils, System.Classes, + System.SysUtils, System.Classes, System.Diagnostics, {$ENDIF} - Horse.Exception, Horse.Exception.Interrupted, Horse.Proc, Horse.Utils, Horse; + Horse.Exception, Horse.Exception.Interrupted, Horse.Proc, Horse.Utils, Horse, Horse.Core; {$IFDEF FPC} function StringToBytes(const AStr: string): TBytes; @@ -167,7 +167,10 @@ constructor TRadixExecutor.Create(ARouter: THorseRadixRouter; AReq: THorseReques end; function TRadixExecutor.Run: Boolean; +var + LStopwatch: TStopwatch; begin + LStopwatch := TStopwatch.StartNew; FResponse.Request := FRequest; GCurrentExecutor := Self; try @@ -182,6 +185,8 @@ function TRadixExecutor.Run: Boolean; end; end; finally + LStopwatch.Stop; + THorseCore.ExecuteOnTelemetry(FRequest, FResponse, LStopwatch.Elapsed.TotalMilliseconds); THorse.ExecuteOnResponse(FRequest, FResponse); end; end; @@ -700,7 +705,9 @@ function THorseRadixRouter.Execute(const ARequest: THorseRequest; const ARespons LResult: Boolean; LRoot: TRadixNode; LGlobalMiddlewares: TList; + LStopwatch: TStopwatch; begin + LStopwatch := TStopwatch.StartNew; LResult := False; AResponse.Request := ARequest; LRoot := FRoot; @@ -857,6 +864,8 @@ function THorseRadixRouter.Execute(const ARequest: THorseRequest; const ARespons end; end; finally + LStopwatch.Stop; + THorseCore.ExecuteOnTelemetry(ARequest, AResponse, LStopwatch.Elapsed.TotalMilliseconds); THorse.ExecuteOnResponse(ARequest, AResponse); end; end; diff --git a/src/Horse.Core.RouterTree.pas b/src/Horse.Core.RouterTree.pas index 43250929..175901cb 100644 --- a/src/Horse.Core.RouterTree.pas +++ b/src/Horse.Core.RouterTree.pas @@ -82,13 +82,15 @@ implementation {$IF DEFINED(FPC)} SysUtils, SyncObjs, + Diagnostics, {$ELSE} System.SysUtils, System.RegularExpressions, System.SyncObjs, + System.Diagnostics, {$ENDIF} Horse.Core.RouterTree.NextCaller, - Horse; + Horse, Horse.Core; threadvar TlsNextCaller: TNextCaller; @@ -158,7 +160,10 @@ constructor TRouterTreeExecutor.Create(ARouter: THorseRouterTree; AReq: THorseRe end; function TRouterTreeExecutor.Run: Boolean; +var + LStopwatch: TStopwatch; begin + LStopwatch := TStopwatch.StartNew; FResponse.Request := FRequest; GCurrentTreeExecutor := Self; try @@ -173,6 +178,8 @@ function TRouterTreeExecutor.Run: Boolean; end; end; finally + LStopwatch.Stop; + THorseCore.ExecuteOnTelemetry(FRequest, FResponse, LStopwatch.Elapsed.TotalMilliseconds); THorse.ExecuteOnResponse(FRequest, FResponse); end; end; @@ -361,7 +368,9 @@ function THorseRouterTree.Execute(const ARequest: THorseRequest; const AResponse LRawWebRequest: {$IF DEFINED(FPC)}TRequest{$ELSE}TWebRequest{$ENDIF}; LBufferNotFound: TBytes; LResult: Boolean; + LStopwatch: TStopwatch; begin + LStopwatch := TStopwatch.StartNew; LResult := False; AResponse.Request := ARequest; try @@ -421,6 +430,8 @@ function THorseRouterTree.Execute(const ARequest: THorseRequest; const AResponse end; AResponse.FlushCookiesToWebResponse; finally + LStopwatch.Stop; + THorseCore.ExecuteOnTelemetry(ARequest, AResponse, LStopwatch.Elapsed.TotalMilliseconds); THorse.ExecuteOnResponse(ARequest, AResponse); end; end; diff --git a/src/Horse.Core.pas b/src/Horse.Core.pas index 3ab8c19c..96effdd1 100644 --- a/src/Horse.Core.pas +++ b/src/Horse.Core.pas @@ -33,9 +33,11 @@ interface {$IF DEFINED(FPC)} THorseOnSendString = procedure(const ARequest: THorseRequest; const AResponse: THorseResponse; var AContent: string); THorseOnSendBytes = procedure(const ARequest: THorseRequest; const AResponse: THorseResponse; var AContent: TBytes); + THorseOnTelemetry = procedure(const ARequest: THorseRequest; const AResponse: THorseResponse; const AExecutionTimeMS: Double); {$ELSE} THorseOnSendString = reference to procedure(const ARequest: THorseRequest; const AResponse: THorseResponse; var AContent: string); THorseOnSendBytes = reference to procedure(const ARequest: THorseRequest; const AResponse: THorseResponse; var AContent: TBytes); + THorseOnTelemetry = reference to procedure(const ARequest: THorseRequest; const AResponse: THorseResponse; const AExecutionTimeMS: Double); {$ENDIF} THorseCore = class; @@ -65,6 +67,7 @@ THorseCore = class(THorseCoreBase) class var FOnSendString: TList; class var FOnSendBytes: TList; class var FOnResponse: TList; + class var FOnTelemetry: TList; class function TrimPath(const APath: string): string; class function RegisterRoute(const AHTTPType: TMethodType; const APath: string; const ACallback: THorseCallback): THorseCore; class function RegisterRouteMiddleware(const AHTTPType: TMethodType; const APath: string; const ACallback: THorseCallback): THorseCore; @@ -117,8 +120,11 @@ THorseCore = class(THorseCoreBase) class procedure AddOnSend(const ACallback: THorseOnSendString); overload; static; class procedure AddOnSend(const ACallback: THorseOnSendBytes); overload; static; class procedure AddOnResponse(const ACallback: THorseCallback); static; + class procedure AddOnTelemetry(const ACallback: THorseOnTelemetry); static; class procedure ResetHooks; static; + class procedure ExecuteOnTelemetry(const ARequest: THorseRequest; const AResponse: THorseResponse; const AExecutionTimeMS: Double); static; + class function GetActiveRequests: Integer; static; class procedure IncrementActiveRequests; static; class procedure DecrementActiveRequests; static; @@ -325,7 +331,8 @@ implementation uses Horse.Core.Route, Horse.Core.Group, - Horse.Constants + Horse.Constants, + Horse.Instance {$IFNDEF FPC} , Horse.Core.Factory {$ENDIF} @@ -592,6 +599,8 @@ class function THorseCore.MakeHorseModule: THorseModule; FreeAndNil(FOnSendBytes); if FOnResponse <> nil then FreeAndNil(FOnResponse); + if FOnTelemetry <> nil then + FreeAndNil(FOnTelemetry); end; {$IF (defined(fpc) or (CompilerVersion > 27.0))} @@ -1364,22 +1373,63 @@ class procedure THorseCore.OnError(const ACallback: THorseOnError); end; class function THorseCore.HasOnError: Boolean; +var + LInstance: THorseCoreBase; begin Result := Assigned(FOnError); + if not Result then + begin + GInstancesLock.Enter; + try + for LInstance in GInstances.Values do + begin + if (LInstance is THorseInstance) and THorseInstance(LInstance).HasOnError then + begin + Result := True; + Break; + end; + end; + finally + GInstancesLock.Leave; + end; + end; end; class procedure THorseCore.ExecuteOnError(const ARequest: THorseRequest; const AResponse: THorseResponse; const AException: Exception); +var + LInstance: THorseCoreBase; + LPort: Integer; + LHandler: THorseOnError; begin - if Assigned(FOnError) then + LPort := 9000; + if Assigned(ARequest) and (ARequest.RawWebRequest <> nil) then + LPort := ARequest.RawWebRequest.ServerPort; + + LInstance := GetHorseInstanceByPort(LPort); + LHandler := nil; + if (LInstance <> nil) and (LInstance is THorseInstance) then + begin + if THorseInstance(LInstance).HasOnError then + LHandler := THorseInstance(LInstance).ErrorHandler; + end; + + if not Assigned(LHandler) then + LHandler := FOnError; + + if Assigned(LHandler) then begin try - FOnError(ARequest, AResponse, AException); + LHandler(ARequest, AResponse, AException); except on E: Exception do begin AResponse.Send('Internal Application Error in OnError: ' + E.Message).Status(THTTPStatus.InternalServerError); end; end; + end + else + begin + raise AException; end; end; @@ -1425,6 +1475,13 @@ class procedure THorseCore.AddOnResponse(const ACallback: THorseCallback); FOnResponse.Add(ACallback); end; +class procedure THorseCore.AddOnTelemetry(const ACallback: THorseOnTelemetry); +begin + if FOnTelemetry = nil then + FOnTelemetry := TList.Create; + FOnTelemetry.Add(ACallback); +end; + class procedure THorseCore.ResetHooks; begin if FOnRequest <> nil then @@ -1439,6 +1496,8 @@ class procedure THorseCore.ResetHooks; FOnSendBytes.Clear; if FOnResponse <> nil then FOnResponse.Clear; + if FOnTelemetry <> nil then + FOnTelemetry.Clear; end; class function THorseCore.GetActiveRequests: Integer; @@ -1477,80 +1536,195 @@ class procedure THorseCore.SetIsShuttingDown(const AValue: Boolean); class procedure THorseCore.ExecuteOnRequest(const ARequest: THorseRequest; const AResponse: THorseResponse; const AOnComplete: TProc); var LExecutor: IHorseLifecycleExecutor; + LInstance: THorseCoreBase; + LPort: Integer; begin - if Assigned(ARequest) and (FOnRequest <> nil) and (FOnRequest.Count > 0) then + LPort := 9000; + if Assigned(ARequest) and (ARequest.RawWebRequest <> nil) then + LPort := ARequest.RawWebRequest.ServerPort; + + LInstance := GetHorseInstanceByPort(LPort); + if (LInstance <> nil) and (LInstance is THorseInstance) then begin - LExecutor := THorseLifecycleExecutor.Create(FOnRequest, ARequest, AResponse, AOnComplete); - LExecutor.Next; + THorseInstance(LInstance).ExecuteOnRequest(ARequest, AResponse, AOnComplete); end else - AOnComplete(); + begin + if Assigned(ARequest) and (FOnRequest <> nil) and (FOnRequest.Count > 0) then + begin + LExecutor := THorseLifecycleExecutor.Create(FOnRequest, ARequest, AResponse, AOnComplete); + LExecutor.Next; + end + else + AOnComplete(); + end; end; class procedure THorseCore.ExecutePreParsing(const ARequest: THorseRequest; const AResponse: THorseResponse; const AOnComplete: TProc); var LExecutor: IHorseLifecycleExecutor; + LInstance: THorseCoreBase; + LPort: Integer; begin - if Assigned(ARequest) and (FPreParsing <> nil) and (FPreParsing.Count > 0) then + LPort := 9000; + if Assigned(ARequest) and (ARequest.RawWebRequest <> nil) then + LPort := ARequest.RawWebRequest.ServerPort; + + LInstance := GetHorseInstanceByPort(LPort); + if (LInstance <> nil) and (LInstance is THorseInstance) then begin - LExecutor := THorseLifecycleExecutor.Create(FPreParsing, ARequest, AResponse, AOnComplete); - LExecutor.Next; + THorseInstance(LInstance).ExecutePreParsing(ARequest, AResponse, AOnComplete); end else - AOnComplete(); + begin + if Assigned(ARequest) and (FPreParsing <> nil) and (FPreParsing.Count > 0) then + begin + LExecutor := THorseLifecycleExecutor.Create(FPreParsing, ARequest, AResponse, AOnComplete); + LExecutor.Next; + end + else + AOnComplete(); + end; end; class procedure THorseCore.ExecutePreValidation(const ARequest: THorseRequest; const AResponse: THorseResponse; const AOnComplete: TProc); var LExecutor: IHorseLifecycleExecutor; + LInstance: THorseCoreBase; + LPort: Integer; begin - if Assigned(ARequest) and (FPreValidation <> nil) and (FPreValidation.Count > 0) then + LPort := 9000; + if Assigned(ARequest) and (ARequest.RawWebRequest <> nil) then + LPort := ARequest.RawWebRequest.ServerPort; + + LInstance := GetHorseInstanceByPort(LPort); + if (LInstance <> nil) and (LInstance is THorseInstance) then begin - LExecutor := THorseLifecycleExecutor.Create(FPreValidation, ARequest, AResponse, AOnComplete); - LExecutor.Next; + THorseInstance(LInstance).ExecutePreValidation(ARequest, AResponse, AOnComplete); end else - AOnComplete(); + begin + if Assigned(ARequest) and (FPreValidation <> nil) and (FPreValidation.Count > 0) then + begin + LExecutor := THorseLifecycleExecutor.Create(FPreValidation, ARequest, AResponse, AOnComplete); + LExecutor.Next; + end + else + AOnComplete(); + end; end; class procedure THorseCore.ExecuteOnSend(const ARequest: THorseRequest; const AResponse: THorseResponse; var AContent: string); var LHook: THorseOnSendString; + LInstance: THorseCoreBase; + LPort: Integer; begin - if Assigned(ARequest) and (FOnSendString <> nil) then + LPort := 9000; + if Assigned(ARequest) and (ARequest.RawWebRequest <> nil) then + LPort := ARequest.RawWebRequest.ServerPort; + + LInstance := GetHorseInstanceByPort(LPort); + if (LInstance <> nil) and (LInstance is THorseInstance) then + begin + THorseInstance(LInstance).ExecuteOnSend(ARequest, AResponse, AContent); + end + else begin - for LHook in FOnSendString do - LHook(ARequest, AResponse, AContent); + if Assigned(ARequest) and (FOnSendString <> nil) then + begin + for LHook in FOnSendString do + LHook(ARequest, AResponse, AContent); + end; end; end; class procedure THorseCore.ExecuteOnSend(const ARequest: THorseRequest; const AResponse: THorseResponse; var AContent: TBytes); var LHook: THorseOnSendBytes; + LInstance: THorseCoreBase; + LPort: Integer; begin - if Assigned(ARequest) and (FOnSendBytes <> nil) then + LPort := 9000; + if Assigned(ARequest) and (ARequest.RawWebRequest <> nil) then + LPort := ARequest.RawWebRequest.ServerPort; + + LInstance := GetHorseInstanceByPort(LPort); + if (LInstance <> nil) and (LInstance is THorseInstance) then + begin + THorseInstance(LInstance).ExecuteOnSend(ARequest, AResponse, AContent); + end + else begin - for LHook in FOnSendBytes do - LHook(ARequest, AResponse, AContent); + if Assigned(ARequest) and (FOnSendBytes <> nil) then + begin + for LHook in FOnSendBytes do + LHook(ARequest, AResponse, AContent); + end; end; end; class procedure THorseCore.ExecuteOnResponse(const ARequest: THorseRequest; const AResponse: THorseResponse); var LCallback: THorseCallback; + LInstance: THorseCoreBase; + LPort: Integer; begin - if Assigned(ARequest) and (FOnResponse <> nil) then + LPort := 9000; + if Assigned(ARequest) and (ARequest.RawWebRequest <> nil) then + LPort := ARequest.RawWebRequest.ServerPort; + + LInstance := GetHorseInstanceByPort(LPort); + if (LInstance <> nil) and (LInstance is THorseInstance) then + begin + THorseInstance(LInstance).ExecuteOnResponse(ARequest, AResponse); + end + else begin - for LCallback in FOnResponse do + if Assigned(ARequest) and (FOnResponse <> nil) then begin - try - {$IF DEFINED(FPC)} - THorseCallbackProc(LCallback)(ARequest, AResponse, GetInstance.EmptyNext); - {$ELSE} - LCallback(ARequest, AResponse, procedure begin end); - {$ENDIF} - except - // Abafar exceções no onResponse para não crashar a finalização da thread de socket + for LCallback in FOnResponse do + begin + try + {$IF DEFINED(FPC)} + THorseCallbackProc(LCallback)(ARequest, AResponse, GetInstance.EmptyNext); + {$ELSE} + LCallback(ARequest, AResponse, procedure begin end); + {$ENDIF} + except + // Abafar exceções no onResponse para não crashar a finalização da thread de socket + end; + end; + end; + end; +end; + +class procedure THorseCore.ExecuteOnTelemetry(const ARequest: THorseRequest; const AResponse: THorseResponse; const AExecutionTimeMS: Double); +var + LCallback: THorseOnTelemetry; + LInstance: THorseCoreBase; + LPort: Integer; +begin + LPort := 9000; + if Assigned(ARequest) and (ARequest.RawWebRequest <> nil) then + LPort := ARequest.RawWebRequest.ServerPort; + + LInstance := GetHorseInstanceByPort(LPort); + if (LInstance <> nil) and (LInstance is THorseInstance) then + begin + THorseInstance(LInstance).ExecuteOnTelemetry(ARequest, AResponse, AExecutionTimeMS); + end + else + begin + if Assigned(ARequest) and (FOnTelemetry <> nil) then + begin + for LCallback in FOnTelemetry do + begin + try + LCallback(ARequest, AResponse, AExecutionTimeMS); + except + // Abafar exceções no OnTelemetry para não crashar a requisição ou o socket + end; end; end; end; diff --git a/src/Horse.Instance.pas b/src/Horse.Instance.pas index 709ac336..b8946232 100644 --- a/src/Horse.Instance.pas +++ b/src/Horse.Instance.pas @@ -48,6 +48,7 @@ THorseInstance = class(THorseCoreBase) FOnSendString: TList; FOnSendBytes: TList; FOnResponse: TList; + FOnTelemetry: TList; FOnError: THorseOnError; FActiveRequests: Integer; FIsShuttingDown: Boolean; @@ -115,6 +116,7 @@ THorseInstance = class(THorseCoreBase) procedure AddOnSend(const ACallback: THorseOnSendString); overload; procedure AddOnSend(const ACallback: THorseOnSendBytes); overload; procedure AddOnResponse(const ACallback: THorseCallback); + procedure AddOnTelemetry(const ACallback: THorseOnTelemetry); procedure ResetHooks; function GetActiveRequests: Integer; @@ -131,6 +133,7 @@ THorseInstance = class(THorseCoreBase) procedure ExecuteOnSend(const ARequest: THorseRequest; const AResponse: THorseResponse; var AContent: string); overload; procedure ExecuteOnSend(const ARequest: THorseRequest; const AResponse: THorseResponse; var AContent: TBytes); overload; procedure ExecuteOnResponse(const ARequest: THorseRequest; const AResponse: THorseResponse); + procedure ExecuteOnTelemetry(const ARequest: THorseRequest; const AResponse: THorseResponse; const AExecutionTimeMS: Double); // Roteamento function Use(const APath: string; const ACallback: THorseCallback): THorseInstance; overload; @@ -283,6 +286,7 @@ THorseInstance = class(THorseCoreBase) property Host: string read FHost write FHost; property Port: Integer read FPort write FPort; property Running: Boolean read FRunning write FRunning; + property ErrorHandler: THorseOnError read FOnError; end; threadvar @@ -387,6 +391,8 @@ destructor THorseInstance.Destroy; FreeAndNil(FOnSendBytes); if FOnResponse <> nil then FreeAndNil(FOnResponse); + if FOnTelemetry <> nil then + FreeAndNil(FOnTelemetry); if FOnBeforeListen <> nil then FreeAndNil(FOnBeforeListen); @@ -592,6 +598,13 @@ procedure THorseInstance.AddOnResponse(const ACallback: THorseCallback); FOnResponse.Add(ACallback); end; +procedure THorseInstance.AddOnTelemetry(const ACallback: THorseOnTelemetry); +begin + if FOnTelemetry = nil then + FOnTelemetry := TList.Create; + FOnTelemetry.Add(ACallback); +end; + procedure THorseInstance.ResetHooks; begin if FOnRequest <> nil then @@ -606,6 +619,8 @@ procedure THorseInstance.ResetHooks; FOnSendBytes.Clear; if FOnResponse <> nil then FOnResponse.Clear; + if FOnTelemetry <> nil then + FOnTelemetry.Clear; FOnError := nil; end; @@ -733,6 +748,23 @@ procedure THorseInstance.ExecuteOnResponse(const ARequest: THorseRequest; const end; end; +procedure THorseInstance.ExecuteOnTelemetry(const ARequest: THorseRequest; const AResponse: THorseResponse; const AExecutionTimeMS: Double); +var + LCallback: THorseOnTelemetry; +begin + if Assigned(ARequest) and (FOnTelemetry <> nil) then + begin + for LCallback in FOnTelemetry do + begin + try + LCallback(ARequest, AResponse, AExecutionTimeMS); + except + // Abafar exceções no OnTelemetry para não quebrar o fluxo + end; + end; + end; +end; + // Roteamento function THorseInstance.Use(const APath: string; const ACallback: THorseCallback): THorseInstance; begin diff --git a/src/Horse.pas b/src/Horse.pas index 85238dec..7d07b276 100644 --- a/src/Horse.pas +++ b/src/Horse.pas @@ -522,6 +522,7 @@ THorse = class(THorseProvider) class function AddOnAfterListen(const ACallback: THorseServerLifecycleMethod): THorse; overload; class function AddOnBeforeStop(const ACallback: THorseServerLifecycleMethod): THorse; overload; class function AddOnAfterStop(const ACallback: THorseServerLifecycleMethod): THorse; overload; + class function AddOnTelemetry(const ACallback: THorseOnTelemetry): THorse; end; implementation @@ -655,4 +656,16 @@ class function THorse.AddOnAfterStop(const ACallback: THorseServerLifecycleMetho THorseProviderAbstract.AddOnAfterStop(ACallback); end; +class function THorse.AddOnTelemetry(const ACallback: THorseOnTelemetry): THorse; +var + LInstance: THorseCoreBase; +begin + Result := THorse(Self); + LInstance := ResolveBuildingInstance; + if (LInstance <> nil) and (LInstance is THorseInstance) then + THorseInstance(LInstance).AddOnTelemetry(ACallback) + else + THorseCore.AddOnTelemetry(ACallback); +end; + end. diff --git a/tests/src/Console.dpr b/tests/src/Console.dpr index 08e4ae70..87851eaf 100644 --- a/tests/src/Console.dpr +++ b/tests/src/Console.dpr @@ -85,6 +85,7 @@ uses Tests.Integration.DependencyInjection in 'tests\Tests.Integration.DependencyInjection.pas', Tests.Integration.MultiInstance in 'tests\Tests.Integration.MultiInstance.pas', Tests.Integration.ServerLifecycle in 'tests\Tests.Integration.ServerLifecycle.pas', + Tests.Integration.Telemetry in 'tests\Tests.Integration.Telemetry.pas', Horse.Mime in '..\..\src\Horse.Mime.pas', Horse.Utils in '..\..\src\Horse.Utils.pas', Horse.Provider.Config in '..\..\src\Horse.Provider.Config.pas', diff --git a/tests/src/tests/Tests.Integration.Telemetry.pas b/tests/src/tests/Tests.Integration.Telemetry.pas new file mode 100644 index 00000000..fd21bd53 --- /dev/null +++ b/tests/src/tests/Tests.Integration.Telemetry.pas @@ -0,0 +1,248 @@ +unit Tests.Integration.Telemetry; + +interface + +uses + DUnitX.TestFramework, Horse, Horse.Commons, System.SysUtils, System.Classes, + System.Threading, System.Net.HttpClient, Tests.CleanupHelper; + +type + [TestFixture] + TTestIntegrationTelemetry = class + private + const TEST_PORT = 9091; + const PORT_INSTANCE_1 = 9092; + const PORT_INSTANCE_2 = 9093; + public + [TearDown] + procedure TearDown; + + [Test] + procedure TestGlobalTelemetryHook; + [Test] + procedure TestMultiInstanceTelemetryIsolation; + [Test] + procedure TestTelemetryOnRouteError; + end; + +implementation + +uses + System.Diagnostics; + +{ TTestIntegrationTelemetry } + +procedure TTestIntegrationTelemetry.TearDown; +begin + THorse.ResetHooks; + ClearGlobalState; +end; + +procedure TTestIntegrationTelemetry.TestGlobalTelemetryHook; +var + LClient: THTTPClient; + LRes: IHTTPResponse; + LThread: TThread; + LTelemetryCalled: Boolean; + LTimeElapsed: Double; + LStatus: Integer; +begin + LTelemetryCalled := False; + LTimeElapsed := 0; + LStatus := 0; + + // Registrar hook de telemetria global + THorse.AddOnTelemetry( + procedure(const Req: THorseRequest; const Res: THorseResponse; const ExecutionTimeMS: Double) + begin + LTelemetryCalled := True; + LTimeElapsed := ExecutionTimeMS; + LStatus := Res.Status; + end); + + THorse.Get('/ping', + procedure(Req: THorseRequest; Res: THorseResponse) + begin + Sleep(100); // Garante que ExecutionTimeMS será > 0 + Res.Send('pong'); + end); + + LThread := TThread.CreateAnonymousThread( + procedure + begin + THorse.Listen(TEST_PORT); + end); + LThread.Start; + Sleep(800); // Aguarda o bind + + LClient := THTTPClient.Create; + try + try + LRes := LClient.Get(Format('http://localhost:%d/ping', [TEST_PORT])); + Assert.AreEqual(200, LRes.StatusCode); + Assert.AreEqual('pong', LRes.ContentAsString); + + Sleep(200); // Aguarda callback de telemetria finalizar (executa no finally) + + Assert.IsTrue(LTelemetryCalled, 'Global telemetry callback should be called.'); + Assert.IsTrue(LTimeElapsed >= 90.0, Format('Elapsed time (%.2f ms) should be at least ~100ms.', [LTimeElapsed])); + Assert.AreEqual(200, LStatus, 'Status should be 200.'); + finally + THorse.StopListen; + Sleep(500); // Aguarda a liberação do socket pelo Windows + end; + finally + LClient.Free; + end; +end; + +procedure TTestIntegrationTelemetry.TestMultiInstanceTelemetryIsolation; +var + LClient: THTTPClient; + LRes: IHTTPResponse; + LThread: TThread; + LInstance1, LInstance2: THorseInstance; + LTelemetryCalled1, LTelemetryCalled2: Boolean; + LPortCalled1, LPortCalled2: Integer; +begin + LTelemetryCalled1 := False; + LTelemetryCalled2 := False; + LPortCalled1 := 0; + LPortCalled2 := 0; + + LInstance1 := THorseInstance.Create; + LInstance1.AddOnTelemetry( + procedure(const Req: THorseRequest; const Res: THorseResponse; const ExecutionTimeMS: Double) + begin + LTelemetryCalled1 := True; + LPortCalled1 := Req.RawWebRequest.ServerPort; + end); + LInstance1.Get('/instance1', + procedure(Req: THorseRequest; Res: THorseResponse) + begin + Res.Send('resp1'); + end); + + LInstance2 := THorseInstance.Create; + LInstance2.AddOnTelemetry( + procedure(const Req: THorseRequest; const Res: THorseResponse; const ExecutionTimeMS: Double) + begin + LTelemetryCalled2 := True; + LPortCalled2 := Req.RawWebRequest.ServerPort; + end); + LInstance2.Get('/instance2', + procedure(Req: THorseRequest; Res: THorseResponse) + begin + Res.Send('resp2'); + end); + + LClient := THTTPClient.Create; + try + // Executa Instância 1 + LThread := TThread.CreateAnonymousThread( + procedure + begin + LInstance1.Listen(PORT_INSTANCE_1); + end); + LThread.Start; + Sleep(800); + + try + LRes := LClient.Get(Format('http://localhost:%d/instance1', [PORT_INSTANCE_1])); + Assert.AreEqual(200, LRes.StatusCode); + Assert.AreEqual('resp1', LRes.ContentAsString); + + Sleep(200); + Assert.IsTrue(LTelemetryCalled1, 'Telemetry 1 should be called'); + Assert.IsFalse(LTelemetryCalled2, 'Telemetry 2 should NOT be called yet'); + Assert.AreEqual(PORT_INSTANCE_1, LPortCalled1); + finally + LInstance1.StopListen; + Sleep(500); + end; + + // Executa Instância 2 + LThread := TThread.CreateAnonymousThread( + procedure + begin + LInstance2.Listen(PORT_INSTANCE_2); + end); + LThread.Start; + Sleep(800); + + try + LRes := LClient.Get(Format('http://localhost:%d/instance2', [PORT_INSTANCE_2])); + Assert.AreEqual(200, LRes.StatusCode); + Assert.AreEqual('resp2', LRes.ContentAsString); + + Sleep(200); + Assert.IsTrue(LTelemetryCalled2, 'Telemetry 2 should be called'); + Assert.AreEqual(PORT_INSTANCE_2, LPortCalled2); + finally + LInstance2.StopListen; + Sleep(500); + end; + + finally + LInstance1.Free; + LInstance2.Free; + LClient.Free; + end; +end; + +procedure TTestIntegrationTelemetry.TestTelemetryOnRouteError; +var + LClient: THTTPClient; + LRes: IHTTPResponse; + LThread: TThread; + LTelemetryCalled: Boolean; + LStatus: Integer; +begin + LTelemetryCalled := False; + LStatus := 0; + + THorse.AddOnTelemetry( + procedure(const Req: THorseRequest; const Res: THorseResponse; const ExecutionTimeMS: Double) + begin + LTelemetryCalled := True; + LStatus := Res.Status; + end); + + // Endpoint que lança um erro propositalmente + THorse.Get('/error', + procedure(Req: THorseRequest; Res: THorseResponse) + begin + raise Exception.Create('Test Exception'); + end); + + LThread := TThread.CreateAnonymousThread( + procedure + begin + THorse.Listen(TEST_PORT); + end); + LThread.Start; + Sleep(800); // Aguarda bind + + LClient := THTTPClient.Create; + try + try + LRes := LClient.Get(Format('http://localhost:%d/error', [TEST_PORT])); + // O Horse retorna 500 por padrão quando há exceção não tratada e nenhum OnError configurado + Assert.AreEqual(500, LRes.StatusCode); + + Sleep(200); + Assert.IsTrue(LTelemetryCalled, 'Telemetry callback should be called even on exceptions.'); + Assert.AreEqual(500, LStatus, 'Response status in telemetry should be 500.'); + finally + THorse.StopListen; + Sleep(500); + end; + finally + LClient.Free; + end; +end; + +initialization + TDUnitX.RegisterTestFixture(TTestIntegrationTelemetry); + +end. From dd68c136f44a6c62f2c7032c09e316ea3473457f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gys=20Borges=20da=20Silveira?= Date: Sat, 11 Jul 2026 10:36:57 -0300 Subject: [PATCH 2/6] docs(agents): update AI instructions with telemetry rules --- .agents/AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 6d3b7b10..0a8e3175 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -32,3 +32,8 @@ Este documento estabelece as regras de design e desenvolvimento do framework Hor * Ao projetar ou atualizar middlewares do ecossistema, garanta que eles não dependam de dados em variáveis globais ou estáticas (`class var` singletons) do core, permitindo que cada instância de `THorseInstance` configure isoladamente suas dependências, rotas e manipuladores. * Para preservar a compatibilidade de compilação cruzada multiplataforma FPC/Lazarus, evite o uso de closures ou procedimentos anônimos inline (`procedure begin end`) em manipuladores de ciclo de vida e rotas lógicas locais das instâncias do Horse, preferindo procedimentos regulares e delegados de objetos. +## 🟢 Observabilidade e Ganchos de Telemetria (Telemetry Hooks) +* O Horse possui infraestrutura nativa e de baixíssimo overhead baseada em `TStopwatch` para rastreamento de latência em requisições. +* Ao estender o ecossistema ou criar novos middlewares de APM/observabilidade (como Prometheus, OpenTelemetry, logging), use sempre o gancho nativo `AddOnTelemetry` (`THorse.AddOnTelemetry` ou `LInstance.AddOnTelemetry`) em vez de introduzir wrappers customizados nos blocos de execução de rotas ou temporizadores ad-hoc que geram overhead de heap. +* Garanta que os callbacks registrados em `AddOnTelemetry` sejam protegidos internamente com blocos `try-except` individuais (silenciando exceções) para assegurar que falhas na coleta de telemetria nunca causem interrupções no fluxo principal de retorno HTTP do cliente ou derrubem a thread de execução do socket. + From a96ed460c1a51c06ed5c46f1dfd94de17c12a6b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gys=20Borges=20da=20Silveira?= Date: Sat, 11 Jul 2026 10:40:36 -0300 Subject: [PATCH 3/6] docs(skills): add horse-telemetry-observability agent skill and update READMEs --- doc/skills/README.md | 1 + doc/skills/README.pt-BR.md | 1 + .../horse-telemetry-observability/SKILL.md | 75 +++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 doc/skills/horse-telemetry-observability/SKILL.md diff --git a/doc/skills/README.md b/doc/skills/README.md index 4aef0c8e..3c5120e3 100644 --- a/doc/skills/README.md +++ b/doc/skills/README.md @@ -31,6 +31,7 @@ | **horse-minimal-api** | [`horse-minimal-api/SKILL.md`](./horse-minimal-api/SKILL.md) | Building rapid, low-boilerplate microservices and mock APIs inside single-file bootstrap models. | | **horse-dependency-injection** | [`horse-dependency-injection/SKILL.md`](./horse-dependency-injection/SKILL.md) | Managing request-scoped contextual services and IoC (dependency injection) in Delphi and Lazarus. | | **horse-multi-instance** | [`horse-multi-instance/SKILL.md`](./horse-multi-instance/SKILL.md) | Running or configuring multiple independent server instances (THorseInstance) concurrently inside the same application process. | +| **horse-telemetry-observability** | [`horse-telemetry-observability/SKILL.md`](./horse-telemetry-observability/SKILL.md) | Registering, configuring, and optimizing native telemetry callbacks (AddOnTelemetry) for APM tools and logging. | --- diff --git a/doc/skills/README.pt-BR.md b/doc/skills/README.pt-BR.md index 3af5a3f3..c6259531 100644 --- a/doc/skills/README.pt-BR.md +++ b/doc/skills/README.pt-BR.md @@ -27,6 +27,7 @@ | **horse-minimal-api** | [`horse-minimal-api/SKILL.md`](./horse-minimal-api/SKILL.md) | Desenvolvimento rápido de microsserviços focados, mocks e APIs de arquivo único estruturadas com baixo boilerplate. | | **horse-dependency-injection** | [`horse-dependency-injection/SKILL.md`](./horse-dependency-injection/SKILL.md) | Gerenciamento de ciclo de vida e IoC no request scope (injeção de dependência) em Delphi e Lazarus. | | **horse-multi-instance** | [`horse-multi-instance/SKILL.md`](./horse-multi-instance/SKILL.md) | Execução ou configuração de múltiplos servidores de instâncias independentes (THorseInstance) concorrentemente dentro do mesmo processo. | +| **horse-telemetry-observability** | [`horse-telemetry-observability/SKILL.md`](./horse-telemetry-observability/SKILL.md) | Registro, configuração e otimização de ganchos nativos de telemetria (AddOnTelemetry) para ferramentas APM e logs. | --- diff --git a/doc/skills/horse-telemetry-observability/SKILL.md b/doc/skills/horse-telemetry-observability/SKILL.md new file mode 100644 index 00000000..9ab37a0f --- /dev/null +++ b/doc/skills/horse-telemetry-observability/SKILL.md @@ -0,0 +1,75 @@ +--- +name: horse-telemetry-observability +description: Guidelines for registering, executing, and optimizing native high-precision telemetry hooks (AddOnTelemetry) in the Horse Web Framework. +--- + +# Horse Telemetry & Observability + +## Native Telemetry Hook (AddOnTelemetry) +Horse provides a high-precision, native, and *Zero-Allocation* telemetry hook based on stack-allocated `TStopwatch`. This hook allows monitoring and logging the total processing latency of all requests passing through the server pipeline. + +The telemetry callback is defined as follows: +```pascal +THorseOnTelemetry = {$IF DEFINED(FPC)}procedure{$ELSE}reference to procedure{$ENDIF}(const ARequest: THorseRequest; const AResponse: THorseResponse; const AExecutionTimeMS: Double); +``` + +--- + +## Registering the Telemetry Hook + +### 1. Global Registration +For applications using the standard static `THorse` facade, register the telemetry hook globally during the bootstrap process: + +```pascal +uses + Horse, System.SysUtils; + +begin + THorse.AddOnTelemetry( + procedure(const Req: THorseRequest; const Res: THorseResponse; const ExecutionTimeMS: Double) + begin + Writeln(Format('[Telemetry] %s %s - Status: %d - Latency: %.2f ms', + [Req.Method, Req.PathInfo, Res.Status, ExecutionTimeMS])); + end); + + THorse.Get('/ping', + procedure(Req: THorseRequest; Res: THorseResponse) + begin + Res.Send('pong'); + end); + + THorse.Listen(9000); +end. +``` + +### 2. Multi-Instance Registration +For applications utilizing `THorseInstance`, register telemetry hooks directly on each instance. Telemetry is fully isolated per port and polymorphically resolved based on the incoming request port: + +```pascal +var + LInstance1, LInstance2: THorseInstance; +begin + LInstance1 := THorseInstance.Create; + LInstance1.AddOnTelemetry( + procedure(const Req: THorseRequest; const Res: THorseResponse; const ExecutionTimeMS: Double) + begin + Writeln(Format('[Instance 1 - Port %d] Latency: %.2f ms', [Req.RawWebRequest.ServerPort, ExecutionTimeMS])); + end); + + LInstance2 := THorseInstance.Create; + LInstance2.AddOnTelemetry( + procedure(const Req: THorseRequest; const Res: THorseResponse; const ExecutionTimeMS: Double) + begin + Writeln(Format('[Instance 2 - Port %d] Latency: %.2f ms', [Req.RawWebRequest.ServerPort, ExecutionTimeMS])); + end); +end; +``` + +--- + +## Design Safeguards & AI Best Practices + +1. **Catch Internal Exceptions**: Always wrap the code inside custom telemetry callbacks with a `try-except` block (or rely on Horse's native try-except boundary) to ensure failures during metrics collecting (like database logging or APM networking errors) never interrupt the HTTP response loop or crash the socket execution thread. +2. **Zero-Allocation Logging**: To maintain Horse's zero-allocation characteristics, avoid dynamic heap allocations (such as concatenating strings or creating new logger objects) inside the telemetry callback. Prefer reusing static buffers or writing to stack-allocated variables. +3. **Multi-Instance Port Resolution**: Always use `Req.RawWebRequest.ServerPort` if you need to determine the active port of the incoming request dynamically inside the telemetry handler. +4. **FPC/Lazarus Compatibility**: In FPC (Lazarus), the callback type is a standard procedural pointer. Do not use inline anonymous methods (`procedure begin end`) when compiling libraries or handlers for Lazarus. From fb5491f8b82ec9cc21da7f6a69d2881d9c1ef8c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gys=20Borges=20da=20Silveira?= Date: Sat, 11 Jul 2026 10:41:54 -0300 Subject: [PATCH 4/6] docs(readme): add AI Agent Skills section to English and Portuguese READMEs --- README.md | 7 +++++++ README.pt-BR.md | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/README.md b/README.md index d92b1f3e..ad9a2038 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,13 @@ These are middlewares focused on application observability, metrics, and tracing | [regyssilveira/horse-opentelemetry](https://github.com/regyssilveira/horse-opentelemetry) |    ✔️ |     ✔️ | | [regyssilveira/horse-prometheus](https://github.com/regyssilveira/horse-prometheus) |    ✔️ |     ✔️ | +## 🤖 AI Agent Skills + +This repository includes native instructions and model skills designed to guide AI agents (such as Gemini, Claude, ChatGPT, and GitHub Copilot) during development. + +* **AI Guidelines:** See [.agents/AGENTS.md](./.agents/AGENTS.md) for core architectural rules on dependency injection, lifecycle hooks, concurrency, and telemetry. +* **Agent Skills:** See [doc/skills/README.md](./doc/skills/README.md) for the complete directory of optimized AI skills and development guides. + ## Delphi Versions `Horse` works with Delphi 13 Florence, Delphi 12 Athens, Delphi 11 Alexandria, Delphi 10.4 Sydney, Delphi 10.3 Rio, Delphi 10.2 Tokyo, Delphi 10.1 Berlin, Delphi 10 Seattle, Delphi XE8 and Delphi XE7. diff --git a/README.pt-BR.md b/README.pt-BR.md index 40eccd5d..0e07584a 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -214,6 +214,13 @@ Estes são middlewares focados em observabilidade, métricas e rastreamento de a | [regyssilveira/horse-opentelemetry](https://github.com/regyssilveira/horse-opentelemetry) |    ✔️ |     ✔️ | | [regyssilveira/horse-prometheus](https://github.com/regyssilveira/horse-prometheus) |    ✔️ |     ✔️ | +## 🤖 Habilidades de IA (Agent Skills) + +Este repositório possui suporte nativo para agentes de inteligência artificial (como Gemini, Claude, ChatGPT e GitHub Copilot) por meio de regras locais e guias de modelagem (skills). + +* **Diretrizes de IA:** Veja [.agents/AGENTS.md](./.agents/AGENTS.md) contendo os padrões arquiteturais de injeção de dependência, lifecycle hooks, concorrência e telemetria. +* **Skills de IA:** Veja [doc/skills/README.pt-BR.md](./doc/skills/README.pt-BR.md) para a lista completa de habilidades de IA (Agent Skills) e guias de desenvolvimento assistido. + ## Versões do Delphi O `Horse` funciona com Delphi 13 Florence, Delphi 12 Athens, Delphi 11 Alexandria, Delphi 10.4 Sydney, Delphi 10.3 Rio, Delphi 10.2 Tokyo, Delphi 10.1 Berlin, Delphi 10 Seattle, Delphi XE8 e Delphi XE7. From 80e2024ac65f2fa99b3f976a7215bfe7a055532a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gys=20Borges=20da=20Silveira?= Date: Sat, 11 Jul 2026 13:17:20 -0300 Subject: [PATCH 5/6] feat(samples): add console telemetry hook sample --- .../console_telemetry/ConsoleTelemetry.dpr | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 samples/delphi/console_telemetry/ConsoleTelemetry.dpr diff --git a/samples/delphi/console_telemetry/ConsoleTelemetry.dpr b/samples/delphi/console_telemetry/ConsoleTelemetry.dpr new file mode 100644 index 00000000..a4c68f8c --- /dev/null +++ b/samples/delphi/console_telemetry/ConsoleTelemetry.dpr @@ -0,0 +1,51 @@ +program ConsoleTelemetry; + +{$IFDEF FPC} + {$MODE DELPHI}{$H+} +{$ENDIF} + +{$APPTYPE CONSOLE} + +uses + {$IFDEF UNIX} + cthreads, + {$ENDIF} + Horse, + {$IFDEF FPC} + SysUtils; + {$ELSE} + System.SysUtils; + {$ENDIF} + +procedure DoPing(Req: THorseRequest; Res: THorseResponse); +begin + Sleep(150); // Simula processamento + Res.Send('pong'); +end; + +procedure DoListen; +begin + Writeln(Format('Telemetry Demo server is running on http://localhost:%d...', [THorse.Port])); +end; + +begin + {$IFDEF MSWINDOWS} + IsConsole := True; + ReportMemoryLeaksOnShutdown := True; + {$ENDIF} + + // Registrar gancho nativo de telemetria + THorse.AddOnTelemetry( + procedure(const Req: THorseRequest; const Res: THorseResponse; const ExecutionTimeMS: Double) + begin + Writeln(Format('[Telemetry Log] %s %s - Status HTTP: %d - Latency: %.2f ms', + [Req.Method, Req.PathInfo, Res.Status, ExecutionTimeMS])); + end); + + THorse.Get('/ping', DoPing); + + THorse.Listen(9000, DoListen); + + while THorse.IsRunning do + Sleep(500); +end. From c1a3c8c2bf9d18344d8b9bb1bb3015826a6e774b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gys=20Borges=20da=20Silveira?= Date: Sat, 11 Jul 2026 13:17:20 -0300 Subject: [PATCH 6/6] fix(tests): fix before listen test failure on iocp and console complete upload parser --- .../console_complete/ConsoleComplete.dpr | 3 ++ .../console_complete/test_integrity.ps1 | 28 ++++++++----------- src/Horse.Provider.IOCP.pas | 6 +--- tests/run_delphi_tests.ps1 | 16 ++++++----- 4 files changed, 25 insertions(+), 28 deletions(-) diff --git a/samples/delphi/console_complete/ConsoleComplete.dpr b/samples/delphi/console_complete/ConsoleComplete.dpr index 8ed7b762..8ab17bc1 100644 --- a/samples/delphi/console_complete/ConsoleComplete.dpr +++ b/samples/delphi/console_complete/ConsoleComplete.dpr @@ -12,6 +12,9 @@ uses {$ENDIF} System.Classes, System.SysUtils, + {$IFNDEF FPC} + Web.ReqMulti, + {$ENDIF} Horse, Horse.Commons; diff --git a/samples/delphi/console_complete/test_integrity.ps1 b/samples/delphi/console_complete/test_integrity.ps1 index f9714406..986815cc 100644 --- a/samples/delphi/console_complete/test_integrity.ps1 +++ b/samples/delphi/console_complete/test_integrity.ps1 @@ -1,5 +1,7 @@ # Script de Validacao de Integridade do Horse - Console Complete $ErrorActionPreference = "Stop" +$script:totalErrors = 0 + function Run-IntegrityTest($RouterName, $Define) { Write-Host "==================================================" -ForegroundColor Cyan @@ -27,7 +29,6 @@ function Run-IntegrityTest($RouterName, $Define) { # 3. Executando chamadas e validacoes Write-Host "[3/5] Executando chamadas HTTP..." -ForegroundColor Yellow - $errors = 0 # Funcao helper de validacao function Assert-Response($TestName, $Actual, $Expected) { @@ -37,7 +38,7 @@ function Run-IntegrityTest($RouterName, $Define) { Write-Host " [FALHA] $TestName" -ForegroundColor Red Write-Host " Esperado: $Expected" -ForegroundColor DarkRed Write-Host " Obtido: $Actual" -ForegroundColor DarkRed - $global:errors++ + $script:totalErrors++ } } @@ -84,7 +85,7 @@ function Run-IntegrityTest($RouterName, $Define) { } else { Write-Host " [FALHA] GET /error-trigger (Clean JSON Exception)" -ForegroundColor Red Write-Host " Obtido: $resErr" -ForegroundColor DarkRed - $errors++ + $script:totalErrors++ } # Testes do Roteamento de Wildcard (*) e prioridade de rotas @@ -116,7 +117,7 @@ function Run-IntegrityTest($RouterName, $Define) { Assert-Response "PUT /api/teste3 (Group Route End Put)" $res "put3" } catch { Write-Host " [FALHA] PUT /api/teste3 falhou com erro HTTP (esperado no bug #357)" -ForegroundColor Red - $global:errors++ + $script:totalErrors++ } # Teste de Resiliencia: Access Violation Simulado (esperamos HTTP 500 sem cair o servidor) @@ -127,11 +128,11 @@ function Run-IntegrityTest($RouterName, $Define) { } else { Write-Host " [FALHA] GET /av-trigger (Access Violation Handled)" -ForegroundColor Red Write-Host " Obtido: $resErr" -ForegroundColor DarkRed - $errors++ + $script:totalErrors++ } } catch { Write-Host " [FALHA] GET /av-trigger falhou ou causou excecao nao tratada no PowerShell: $_" -ForegroundColor Red - $errors++ + $script:totalErrors++ } # Teste de Resiliencia: Stack Overflow Simulado (estouro de limite de pilha) @@ -148,7 +149,7 @@ function Run-IntegrityTest($RouterName, $Define) { } catch { Write-Host "Erro inesperado ao realizar chamadas HTTP: $_" -ForegroundColor Red - $errors++ + $script:totalErrors++ } # 4. Finalizando o Servidor @@ -159,23 +160,18 @@ function Run-IntegrityTest($RouterName, $Define) { # 5. Limpeza de arquivos gerados Write-Host "[5/5] Limpando executavel e arquivos temporarios..." -ForegroundColor Yellow Remove-Item -Path "ConsoleComplete.exe", "ConsoleComplete.dcu" -Force -ErrorAction SilentlyContinue - - return $errors } # Executando rodadas de teste -$totalErrors = 0 -$errorsDefault = Run-IntegrityTest -RouterName "Default Router (RouterTree)" -Define "" -$errorsRadix = Run-IntegrityTest -RouterName "Radix Router (RadixRouter)" -Define "HORSE_RADIX_ROUTER" - -$totalErrors = $errorsDefault + $errorsRadix +Run-IntegrityTest -RouterName "Default Router (RouterTree)" -Define "" +Run-IntegrityTest -RouterName "Radix Router (RadixRouter)" -Define "HORSE_RADIX_ROUTER" Write-Host "==================================================" -ForegroundColor Cyan -if ($totalErrors -eq 0) { +if ($script:totalErrors -eq 0) { Write-Host " TODOS OS TESTES DE INTEGRIDADE PASSARAM! " -ForegroundColor Green Write-Host "==================================================" -ForegroundColor Green } else { - Write-Host " OCORRERAM $totalErrors FALHA(S) NO TOTAL DOS TESTES! " -ForegroundColor Red + Write-Host " OCORRERAM $script:totalErrors FALHA(S) NO TOTAL DOS TESTES! " -ForegroundColor Red Write-Host "==================================================" -ForegroundColor Red exit 1 } diff --git a/src/Horse.Provider.IOCP.pas b/src/Horse.Provider.IOCP.pas index 063ff038..9615dfea 100644 --- a/src/Horse.Provider.IOCP.pas +++ b/src/Horse.Provider.IOCP.pas @@ -1518,11 +1518,7 @@ class procedure THorseProviderIOCP.InternalStopListen; class procedure THorseProviderIOCP.InternalListenLoop(const ACallbackListen, ACallbackStopListen: Horse.Proc.TProc); begin - try - InternalListen; - except - Exit; - end; + InternalListen; DoOnListen; while FRunning do Sleep(100); diff --git a/tests/run_delphi_tests.ps1 b/tests/run_delphi_tests.ps1 index d8c42b9b..e83142be 100644 --- a/tests/run_delphi_tests.ps1 +++ b/tests/run_delphi_tests.ps1 @@ -45,12 +45,12 @@ $FriendlyVersions = @{ # Cenários de defines a serem testados (injetados no arquivo .inc antes do build) $DefinesToTest = @( - @{ Name = "Default"; Flags = '{$DEFINE CI}' }, - @{ Name = "Default+Radix"; Flags = '{$DEFINE CI}' + "`r`n" + '{$DEFINE HORSE_RADIX_ROUTER}' }, - @{ Name = "HttpSys"; Flags = '{$DEFINE CI}' + "`r`n" + '{$DEFINE HORSE_PROVIDER_HTTPSYS}' }, - @{ Name = "HttpSys+Radix"; Flags = '{$DEFINE CI}' + "`r`n" + '{$DEFINE HORSE_PROVIDER_HTTPSYS}' + "`r`n" + '{$DEFINE HORSE_RADIX_ROUTER}' }, - @{ Name = "IOCP"; Flags = '{$DEFINE CI}' + "`r`n" + '{$DEFINE HORSE_PROVIDER_IOCP}' }, - @{ Name = "IOCP+Radix"; Flags = '{$DEFINE CI}' + "`r`n" + '{$DEFINE HORSE_PROVIDER_IOCP}' + "`r`n" + '{$DEFINE HORSE_RADIX_ROUTER}' } + @{ Name = "Default"; Flags = '{$DEFINE CI}'; DccFlags = "CI" }, + @{ Name = "Default+Radix"; Flags = '{$DEFINE CI}' + "`r`n" + '{$DEFINE HORSE_RADIX_ROUTER}'; DccFlags = "CI;HORSE_RADIX_ROUTER" }, + @{ Name = "HttpSys"; Flags = '{$DEFINE CI}' + "`r`n" + '{$DEFINE HORSE_PROVIDER_HTTPSYS}'; DccFlags = "CI;HORSE_PROVIDER_HTTPSYS" }, + @{ Name = "HttpSys+Radix"; Flags = '{$DEFINE CI}' + "`r`n" + '{$DEFINE HORSE_PROVIDER_HTTPSYS}' + "`r`n" + '{$DEFINE HORSE_RADIX_ROUTER}'; DccFlags = "CI;HORSE_PROVIDER_HTTPSYS;HORSE_RADIX_ROUTER" }, + @{ Name = "IOCP"; Flags = '{$DEFINE CI}' + "`r`n" + '{$DEFINE HORSE_PROVIDER_IOCP}'; DccFlags = "CI;HORSE_PROVIDER_IOCP" }, + @{ Name = "IOCP+Radix"; Flags = '{$DEFINE CI}' + "`r`n" + '{$DEFINE HORSE_PROVIDER_IOCP}' + "`r`n" + '{$DEFINE HORSE_RADIX_ROUTER}'; DccFlags = "CI;HORSE_PROVIDER_IOCP;HORSE_RADIX_ROUTER" } ) # Verifica se o diretório do Studio existe @@ -132,7 +132,7 @@ foreach ($Inst in $Installations) { # 4. Compilação direta usando dcc32.exe a partir da pasta tests/src/ Write-Host " -> Compilando diretamente com dcc32.exe..." -ForegroundColor Gray - $BuildCommand = "call `"{0}`" && cd /d `"{1}\src`" && dcc32.exe Console.dpr" -f $RsvarsPath, $ScriptDir + $BuildCommand = "call `"{0}`" && cd /d `"{1}\src`" && dcc32.exe -D{2} Console.dpr" -f $RsvarsPath, $ScriptDir, $Def.DccFlags $BuildOutput = cmd.exe /c $BuildCommand 2>&1 $BuildExitCode = $LASTEXITCODE @@ -161,8 +161,10 @@ foreach ($Inst in $Installations) { Write-Host " -> Executando suíte de testes..." -ForegroundColor Gray # Executa o executável DUnitX enviando uma entrada vazia via pipe para evitar qualquer Readln bloqueante + $env:HORSE_TEST_SILENCE = "1" $TestOutput = "" | & $OutputExe 2>&1 $TestExitCode = $LASTEXITCODE + $env:HORSE_TEST_SILENCE = $null if ($TestExitCode -eq 0) { $TestsPassed = $true