diff --git a/apps/meteor/app/api/server/api.ts b/apps/meteor/app/api/server/api.ts index dbafa020706f8..a9c26341c3fb2 100644 --- a/apps/meteor/app/api/server/api.ts +++ b/apps/meteor/app/api/server/api.ts @@ -1,6 +1,7 @@ import type { IRoom } from '@rocket.chat/core-typings'; import type { Router } from '@rocket.chat/http-router'; import { Logger } from '@rocket.chat/logger'; +import { tracerSpanMiddleware } from '@rocket.chat/tracing'; import type express from 'express'; import { WebApp } from 'meteor/webapp'; @@ -9,7 +10,6 @@ import { cors } from './middlewares/cors'; import { loggerMiddleware } from './middlewares/logger'; import { metricsMiddleware } from './middlewares/metrics'; import { remoteAddressMiddleware } from './middlewares/remoteAddressMiddleware'; -import { tracerSpanMiddleware } from './middlewares/tracer'; import { type APIActionHandler, RocketChatAPIRouter } from './router'; import { metrics } from '../../metrics/server'; import { settings } from '../../settings/server'; @@ -106,7 +106,7 @@ export const startRestAPI = () => { .use(cors(settings)) .use(loggerMiddleware(logger)) .use(metricsMiddleware({ basePathRegex: new RegExp(/^\/api\/v1\//), api: API.v1, settings, summary: metrics.rocketchatRestApi })) - .use(tracerSpanMiddleware) + .use(tracerSpanMiddleware()) .use(API.v1.router) .use(API.default.router).router, ); diff --git a/apps/meteor/app/api/server/middlewares/tracer.ts b/apps/meteor/app/api/server/middlewares/tracer.ts deleted file mode 100644 index bc3f03778cda7..0000000000000 --- a/apps/meteor/app/api/server/middlewares/tracer.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { tracerSpan } from '@rocket.chat/tracing'; -import type { MiddlewareHandler } from 'hono'; - -export const tracerSpanMiddleware: MiddlewareHandler = async (c, next) => { - return tracerSpan( - `${c.req.method} ${c.req.url}`, - { - attributes: { - url: c.req.url, - // route: c.req.route?.path, - method: c.req.method, - userId: (c.req.raw.clone() as any).userId, // Assuming userId is attached to the request object - }, - }, - async (span) => { - if (span) { - c.header('X-Trace-Id', span.spanContext().traceId); - } - - await next(); - - span?.setAttribute('status', c.res.status); - }, - ); -}; diff --git a/apps/meteor/app/integrations/server/api/api.ts b/apps/meteor/app/integrations/server/api/api.ts index 8e1a37c8e76fa..77df6144a4594 100644 --- a/apps/meteor/app/integrations/server/api/api.ts +++ b/apps/meteor/app/integrations/server/api/api.ts @@ -2,6 +2,7 @@ import type { IIncomingIntegration, IIntegration, IOutgoingIntegration, IUser, R import { Integrations, Users } from '@rocket.chat/models'; import { Random } from '@rocket.chat/random'; import { isIntegrationsHooksAddSchema, isIntegrationsHooksRemoveSchema } from '@rocket.chat/rest-typings'; +import { tracerSpanMiddleware } from '@rocket.chat/tracing'; import type express from 'express'; import type { Context, Next } from 'hono'; import { Meteor } from 'meteor/meteor'; @@ -16,7 +17,6 @@ import { API, defaultRateLimiterOptions } from '../../../api/server/api'; import type { FailureResult, GenericRouteExecutionContext, SuccessResult, UnavailableResult } from '../../../api/server/definition'; import { loggerMiddleware } from '../../../api/server/middlewares/logger'; import { metricsMiddleware } from '../../../api/server/middlewares/metrics'; -import { tracerSpanMiddleware } from '../../../api/server/middlewares/tracer'; import type { WebhookResponseItem } from '../../../lib/server/functions/processWebhookMessage'; import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage'; import { metrics } from '../../../metrics/server'; @@ -386,7 +386,7 @@ const Api = new WebHookAPI({ Api.router .use(loggerMiddleware(integrationLogger)) .use(metricsMiddleware({ basePathRegex: new RegExp(/^\/hooks\//), api: Api, settings, summary: metrics.rocketchatRestApi })) - .use(tracerSpanMiddleware); + .use(tracerSpanMiddleware()); const middleware = async (c: Context, next: Next): Promise => { const { req } = c; diff --git a/apps/meteor/ee/server/apps/communication/rest.ts b/apps/meteor/ee/server/apps/communication/rest.ts index a886844692f5f..339c24607ee72 100644 --- a/apps/meteor/ee/server/apps/communication/rest.ts +++ b/apps/meteor/ee/server/apps/communication/rest.ts @@ -8,6 +8,7 @@ import { License } from '@rocket.chat/license'; import { Logger } from '@rocket.chat/logger'; import { Settings, Users } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; +import { tracerSpanMiddleware } from '@rocket.chat/tracing'; import { Meteor } from 'meteor/meteor'; import * as z from 'zod'; @@ -22,7 +23,6 @@ import type { APIClass } from '../../../../app/api/server/ApiClass'; import { getUploadFormData } from '../../../../app/api/server/lib/getUploadFormData'; import { loggerMiddleware } from '../../../../app/api/server/middlewares/logger'; import { metricsMiddleware } from '../../../../app/api/server/middlewares/metrics'; -import { tracerSpanMiddleware } from '../../../../app/api/server/middlewares/tracer'; import { getWorkspaceAccessToken, getWorkspaceAccessTokenWithScope } from '../../../../app/cloud/server'; import { metrics } from '../../../../app/metrics/server'; import { settings } from '../../../../app/settings/server'; @@ -74,7 +74,7 @@ export class AppsRestApi { this.api.router .use(loggerMiddleware(logger)) .use(metricsMiddleware({ basePathRegex: new RegExp(/^\/api\/apps\//), api: this.api, settings, summary: metrics.rocketchatRestApi })) - .use(tracerSpanMiddleware); + .use(tracerSpanMiddleware()); this.addManagementRoutes(); // Using the same instance of the existing API for now, to be able to use the same api prefix(/api) diff --git a/ee/packages/federation-matrix/README.md b/ee/packages/federation-matrix/README.md index 12e9e9eeab811..5a35551a4c093 100644 --- a/ee/packages/federation-matrix/README.md +++ b/ee/packages/federation-matrix/README.md @@ -25,45 +25,60 @@ The integration test script builds Rocket.Chat locally, starts federation servic - `--keep-running`: Keeps containers running after tests complete for manual validation - `--element`: Includes Element web client in the test environment - `--no-test`: Starts containers and skips running tests (useful for manual testing or debugging) +- `--observability`: Enables observability services (Grafana, Prometheus, Tempo) for tracing and metrics collection. Always includes Element web client and automatically keeps containers running after tests complete. Use this flag when you want to test observability code to verify that traces and metrics are being collected correctly. This requires manual testing/verification in Grafana and Prometheus, but the script helps set up the environment. ### Usage Examples **Basic local testing:** + ```bash yarn test:integration ``` **Test with pre-built image:** + ```bash yarn test:integration --image ``` **Test with specific pre-built image:** + ```bash yarn test:integration --image rocketchat/rocket.chat:latest ``` **Keep services running for manual inspection:** + ```bash yarn test:integration --keep-running ``` **Run with Element client:** + ```bash yarn test:integration --element ``` **Start containers only (skip tests):** + ```bash yarn test:integration --no-test ``` **Start containers with Element and keep them running (skip tests):** + ```bash yarn test:integration --keep-running --element --no-test ``` +**Run with observability enabled:** + +```bash +yarn test:integration --observability +``` + **Combine flags:** + ```bash yarn test:integration --image rocketchat/rocket.chat:latest --keep-running --element ``` @@ -71,6 +86,24 @@ yarn test:integration --image rocketchat/rocket.chat:latest --keep-running --ele ### Service URLs (when using --keep-running or --no-test) - **Rocket.Chat**: https://rc1 -- **Synapse**: https://hs1 +- **Synapse**: https://hs1 - **MongoDB**: localhost:27017 -- **Element**: https://element (when using --element flag) +- **Element**: https://element (when using --element flag or --observability flag) + +### Observability Services (when using --observability flag) + +When the `--observability` flag is enabled, the following services are started for tracing and metrics collection: + +- **Grafana**: http://localhost:4001 - Visualization dashboard for traces and metrics +- **Prometheus**: http://localhost:9090 - Metrics collection and querying +- **Tempo**: http://localhost:3200 - Distributed tracing backend + +The observability services are automatically configured to: + +- Collect traces from Rocket.Chat via OpenTelemetry (OTLP) on port 4317 +- Scrape Prometheus metrics from Rocket.Chat on port 9458 +- Display traces and metrics in Grafana with pre-configured datasources + +**Use Case**: Use the `--observability` flag when you want to test observability code to see if it's collecting the right information. This requires manual testing and verification - you'll need to interact with the federation services, then check Grafana and Prometheus to verify that traces and metrics are being collected correctly. The script helps set up the environment, but the actual verification must be done manually. + +**Note**: The `--observability` flag automatically keeps containers running after tests complete (equivalent to `--keep-running`) and always includes Element web client. You don't need to specify `--element` separately when using `--observability`. diff --git a/ee/packages/federation-matrix/TRACING_ENTRY_POINTS.md b/ee/packages/federation-matrix/TRACING_ENTRY_POINTS.md new file mode 100644 index 0000000000000..c8927be77af73 --- /dev/null +++ b/ee/packages/federation-matrix/TRACING_ENTRY_POINTS.md @@ -0,0 +1,72 @@ +# Federation Tracing Entry Points + +This document identifies the entry points where root traces should be created for federation operations. + +## Entry Points for Root Traces + +### 1. Incoming Messages from Matrix Transactions + +**Entry Point:** `PUT /_matrix/federation/v1/send/:txnId` + +- **Location:** `ee/packages/federation-matrix/src/api/_matrix/transactions.ts:335` +- **Flow:** + 1. HTTP endpoint receives transaction → creates root trace (via tracer middleware) + 2. Calls `federationSDK.processIncomingTransaction(body)` + 3. Which calls `eventService.notify()` → `event.service.ts:907` + 4. Which emits `eventEmitterService.emit('homeserver.matrix.message', ...)` → `event.service.ts:925` + 5. Which is handled by `federationSDK.eventEmitterService.on('homeserver.matrix.message', ...)` → `message.ts:114` + +**Problem:** The `homeserver.matrix.message` event becomes a child of the HTTP request trace, but it should be its own root trace since it represents an independent operation. + +**Solution:** When emitting `homeserver.matrix.*` events from `notify()`, they should create root spans by using `ROOT_CONTEXT` instead of the active context. + +### 2. Incoming Invites from Matrix Transactions + +**Entry Point:** `PUT /_matrix/federation/v1/send/:txnId` (same as messages) + +- **Flow:** + 1. HTTP endpoint → `federationSDK.processIncomingTransaction()` + 2. → `eventService.notify()` → emits `homeserver.matrix.membership` (invite type) + 3. → Handled by `member.ts:120` (`handleInvite`) + +**Problem:** Same as messages - becomes child of HTTP request trace. + +### 3. Incoming Invites from Matrix Invite Endpoint + +**Entry Point:** `PUT /_matrix/federation/v2/invite/:roomId/:eventId` + +- **Location:** `ee/packages/federation-matrix/src/api/_matrix/invite.ts:135` +- **Flow:** + 1. HTTP endpoint → creates root trace (via tracer middleware) + 2. Calls `federationSDK.processInvite(event, eventId, roomVersion, strippedStateEvents)` + 3. Which eventually processes the invite and may emit events + +**Note:** This endpoint should already be a root trace via the tracer middleware. + +### 4. Outgoing Invites (User Calls Invite Endpoint) + +**Entry Point:** `POST /v1/rooms.invite` + +- **Location:** `apps/meteor/app/api/server/v1/rooms.ts:1083` +- **Flow:** + 1. HTTP endpoint → creates root trace (via tracer middleware) + 2. Calls `FederationMatrix.handleInvite(roomId, userId, action)` OR + 3. Triggers `beforeAddUserToRoom` hook → `ee/server/hooks/federation/index.ts:105` + 4. Which calls `FederationMatrix.inviteUsersToRoom(room, [user.username], inviter)` → `FederationMatrix.ts:581` + 5. Which calls `federationSDK.inviteUserToRoom()` + +**Note:** This endpoint should already be a root trace via the tracer middleware. + +## Current Problem + +When `eventEmitterService.emit('homeserver.matrix.*')` is called from within `notify()`, it's being called from within the context of the HTTP request trace (`PUT /_matrix/federation/v1/send/:txnId`). This causes all `homeserver.matrix.*` events to become children of the HTTP request trace instead of being independent root traces. + +## Solution + +Modify the `EventEmitterService.emit()` method in the homeserver SDK to: + +1. Detect when emitting `homeserver.*` events (incoming Matrix operations) +2. Use `ROOT_CONTEXT` instead of the active context to create root spans +3. This ensures each incoming Matrix operation gets its own independent trace + +The same should be done for `EventEmitterService.on()` handlers to ensure they also create root spans when handling incoming Matrix events. diff --git a/ee/packages/federation-matrix/docker-compose.test.yml b/ee/packages/federation-matrix/docker-compose.test.yml index 8b34c6509cec2..8de74d3ef6cbd 100644 --- a/ee/packages/federation-matrix/docker-compose.test.yml +++ b/ee/packages/federation-matrix/docker-compose.test.yml @@ -2,6 +2,12 @@ networks: hs1-net: rc1-net: element-net: + observability-net: + + +volumes: + tempo-data: + services: traefik: @@ -9,6 +15,7 @@ services: profiles: - test - element + - observability command: - "--api.insecure=true" # - "--log.level=DEBUG" @@ -33,18 +40,21 @@ services: # resolution which tries to communicate with the container directly some times and which does # not provide SSL neither the correct exposed ports. hs1-net: - aliases: [hs1, rc1, rc.host] + aliases: [ hs1, rc1, rc.host ] rc1-net: - aliases: [hs1, rc1, rc.host] + aliases: [ hs1, rc1, rc.host ] element-net: - aliases: [hs1, rc1, rc.host, element] + aliases: [ hs1, rc1, rc.host, element ] + observability-net: + aliases: [ hs1, rc1, rc.host ] -# HomeServer 1 (synapse) + # HomeServer 1 (synapse) hs1: image: matrixdotorg/synapse:latest profiles: - test - element + - observability entrypoint: | sh -c "update-ca-certificates && @@ -77,7 +87,7 @@ services: - "traefik.http.routers.hs1.tls=true" - "traefik.http.services.hs1.loadbalancer.server.port=8008" -# Rocket.Chat rc1 + # Rocket.Chat rc1 rc1: build: context: ${ROCKETCHAT_BUILD_CONTEXT:-./test/dist} @@ -86,6 +96,7 @@ services: profiles: - test - element + - observability environment: ROOT_URL: https://rc1 PORT: 3000 @@ -102,10 +113,15 @@ services: ADMIN_PASS: admin ADMIN_EMAIL: admin@admin.com TEST_MODE: true + TRACING_ENABLED: "true" + OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4317" + OVERWRITE_SETTING_Prometheus_Enabled: "true" + OVERWRITE_SETTING_Prometheus_Port: "9458" volumes: - ./docker-compose/traefik/certs/ca/rootCA.crt:/usr/local/share/ca-certificates/rootCA.pem networks: - rc1-net + - observability-net depends_on: - mongo labels: @@ -124,6 +140,7 @@ services: profiles: - test - element + - observability restart: on-failure ports: - "27017:27017" @@ -146,6 +163,7 @@ services: image: vectorim/element-web profiles: - element + - observability # ports: # - "8080:80" volumes: @@ -162,3 +180,98 @@ services: - "traefik.http.middlewares.element.redirectscheme.scheme=https" - "traefik.http.routers.element-http.rule=Host(`element`)" - "traefik.http.routers.element-http.middlewares=element" + + # Observability services + # Tempo runs as user 10001, and docker compose creates the volume as root. + # As such, we need to chown the volume in order for Tempo to start correctly. + # Using a named volume (not external) so data is fresh on each run. + init: + image: &tempoImage grafana/tempo:2.6.1 + user: root + entrypoint: + - 'chown' + - '10001:10001' + - '/var/tempo' + volumes: + - tempo-data:/var/tempo + profiles: + - observability + networks: + - observability-net + + tempo: + image: *tempoImage + command: [ '-config.file=/etc/tempo.yaml' ] + volumes: + - ./docker-compose/observability/tempo.yml:/etc/tempo.yaml + - tempo-data:/var/tempo + ports: + - '14268' # jaeger ingest + - '3200:3200' # tempo + - '4317' # otlp grpc + - '4318' # otlp http + - '9411' # zipkin + depends_on: + - init + profiles: + - observability + networks: + - observability-net + + otel-collector: + image: otel/opentelemetry-collector-contrib:0.100.0 + command: + - '--config' + - '/otel-local-config.yaml' + volumes: + - ./docker-compose/observability/collector.config.yml:/otel-local-config.yaml + ports: + - '4317:4317' + profiles: + - observability + networks: + - observability-net + + agent: + image: grafana/agent:v0.27.1 + volumes: + - ./docker-compose/observability/agent.yml:/etc/agent.yaml + entrypoint: + - /bin/agent + - -config.file=/etc/agent.yaml + profiles: + - observability + networks: + - observability-net + + prometheus: + image: prom/prometheus:v3.2.1 + command: + - --config.file=/etc/prometheus.yaml + - --web.enable-remote-write-receiver + - --enable-feature=exemplar-storage + - --enable-feature=native-histograms + volumes: + - ./docker-compose/observability/prometheus.yml:/etc/prometheus.yaml + ports: + - '9090:9090' + profiles: + - observability + networks: + - observability-net + + grafana: + image: grafana/grafana:11.0.0 + volumes: + - ./docker-compose/observability/grafana-datasources.yml:/etc/grafana/provisioning/datasources/datasources.yaml + environment: + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + - GF_AUTH_DISABLE_LOGIN_FORM=true + - GF_FEATURE_TOGGLES_ENABLE=traceqlEditor + ports: + - '4001:3000' + profiles: + - observability + networks: + - observability-net diff --git a/ee/packages/federation-matrix/docker-compose/observability/agent.yml b/ee/packages/federation-matrix/docker-compose/observability/agent.yml new file mode 100644 index 0000000000000..597c2ab6d19c1 --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose/observability/agent.yml @@ -0,0 +1,17 @@ +server: + log_level: debug + +traces: + configs: + - name: default + receivers: + otlp: + protocols: + grpc: + remote_write: + - endpoint: tempo:4317 + insecure: true + batch: + timeout: 5s + send_batch_size: 100 + diff --git a/ee/packages/federation-matrix/docker-compose/observability/collector.config.yml b/ee/packages/federation-matrix/docker-compose/observability/collector.config.yml new file mode 100644 index 0000000000000..a284c46de8077 --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose/observability/collector.config.yml @@ -0,0 +1,24 @@ +receivers: + otlp: + protocols: + grpc: + http: + +processors: + batch: + timeout: 100ms + +exporters: + logging: + loglevel: debug + otlp/1: + endpoint: tempo:4317 + tls: + insecure: true + +service: + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [otlp/1] diff --git a/ee/packages/federation-matrix/docker-compose/observability/grafana-datasources.yml b/ee/packages/federation-matrix/docker-compose/observability/grafana-datasources.yml new file mode 100644 index 0000000000000..a6ee31475eab0 --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose/observability/grafana-datasources.yml @@ -0,0 +1,31 @@ +apiVersion: 1 + +datasources: +- name: Prometheus + type: prometheus + uid: prometheus + access: proxy + orgId: 1 + url: http://prometheus:9090 + basicAuth: false + isDefault: false + version: 1 + editable: false + jsonData: + httpMethod: GET +- name: Tempo + type: tempo + access: proxy + orgId: 1 + url: http://tempo:3200 + basicAuth: false + isDefault: true + version: 1 + editable: false + apiVersion: 1 + uid: tempo + jsonData: + httpMethod: GET + serviceMap: + datasourceUid: prometheus + diff --git a/ee/packages/federation-matrix/docker-compose/observability/prometheus.yml b/ee/packages/federation-matrix/docker-compose/observability/prometheus.yml new file mode 100644 index 0000000000000..28985a90bba84 --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose/observability/prometheus.yml @@ -0,0 +1,15 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + - job_name: 'tempo' + static_configs: + - targets: ['tempo:3200'] + - job_name: 'rocketchat' + static_configs: + - targets: ['rc1:9458'] + diff --git a/ee/packages/federation-matrix/docker-compose/observability/tempo.yml b/ee/packages/federation-matrix/docker-compose/observability/tempo.yml new file mode 100644 index 0000000000000..1edfe61527f62 --- /dev/null +++ b/ee/packages/federation-matrix/docker-compose/observability/tempo.yml @@ -0,0 +1,60 @@ +stream_over_http_enabled: true +server: + http_listen_port: 3200 + log_level: info + +query_frontend: + search: + duration_slo: 5s + throughput_bytes_slo: 1.073741824e+09 + trace_by_id: + duration_slo: 5s + +distributor: + receivers: # this configuration will listen on all ports and protocols that tempo is capable of. + jaeger: # the receives all come from the OpenTelemetry collector. more configuration information can + protocols: # be found there: https://github.com/open-telemetry/opentelemetry-collector/tree/main/receiver + thrift_http: # + grpc: # for a production deployment you should only enable the receivers you need! + thrift_binary: + thrift_compact: + zipkin: + otlp: + protocols: + http: + grpc: + opencensus: + +ingester: + max_block_duration: 5m # cut the headblock when this much time passes. this is being set for demo purposes and should probably be left alone normally + +compactor: + compaction: + block_retention: 168h # overall Tempo trace retention (1 week) + +metrics_generator: + registry: + external_labels: + source: tempo + cluster: docker-compose + storage: + path: /var/tempo/generator/wal + remote_write: + - url: http://prometheus:9090/api/v1/write + send_exemplars: true + traces_storage: + path: /var/tempo/generator/traces + +storage: + trace: + backend: local # backend configuration to use + wal: + path: /var/tempo/wal # where to store the wal locally + local: + path: /var/tempo/blocks + +overrides: + defaults: + metrics_generator: + processors: [service-graphs, span-metrics, local-blocks] # enables metrics generator + generate_native_histograms: both diff --git a/ee/packages/federation-matrix/package.json b/ee/packages/federation-matrix/package.json index 7e205339a76df..4518af0e943ea 100644 --- a/ee/packages/federation-matrix/package.json +++ b/ee/packages/federation-matrix/package.json @@ -28,6 +28,7 @@ "@rocket.chat/models": "workspace:^", "@rocket.chat/network-broker": "workspace:^", "@rocket.chat/rest-typings": "workspace:^", + "@rocket.chat/tracing": "workspace:^", "emojione": "^4.5.0", "marked": "^16.1.2", "mongodb": "6.16.0", diff --git a/ee/packages/federation-matrix/src/FederationMatrix.ts b/ee/packages/federation-matrix/src/FederationMatrix.ts index 332d393c1d18c..0dbdabeb3bef8 100644 --- a/ee/packages/federation-matrix/src/FederationMatrix.ts +++ b/ee/packages/federation-matrix/src/FederationMatrix.ts @@ -12,6 +12,7 @@ import { eventIdSchema, roomIdSchema, userIdSchema, federationSDK, FederationReq import type { EventID, FileMessageType, PresenceState } from '@rocket.chat/federation-sdk'; import { Logger } from '@rocket.chat/logger'; import { Users, Subscriptions, Messages, Rooms, Settings } from '@rocket.chat/models'; +import { addSpanAttributes, traced, tracedClass } from '@rocket.chat/tracing'; import emojione from 'emojione'; import { createOrUpdateFederatedUser } from './helpers/createOrUpdateFederatedUser'; @@ -27,6 +28,7 @@ export const fileTypes: Record = { file: 'm.file', }; +@tracedClass({ type: 'service' }) export class FederationMatrix extends ServiceClass implements IFederationMatrixService { protected name = 'federation-matrix'; @@ -38,6 +40,10 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS private readonly logger = new Logger(this.name); + constructor() { + super(); + } + override async created(): Promise { // although this is async function, it is not awaited, so we need to register the listeners before everything else this.onEvent('watch.settings', async ({ clientAction, setting }): Promise => { @@ -100,6 +106,13 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS this.processEDUPresence = (await Settings.getValueById('Federation_Service_EDU_Process_Presence')) || false; } + @traced((room: IRoom, owner: IUser) => ({ + roomId: room?._id, + roomName: room?.name || room?.fname, + roomType: room?.t, + ownerId: owner?._id, + ownerUsername: owner?.username, + })) async createRoom(room: IRoom, owner: IUser): Promise<{ room_id: string; event_id: string }> { if (room.t !== 'c' && room.t !== 'p') { throw new Error('Room is not a public or private room'); @@ -112,6 +125,14 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS // canonical alias computed from name const matrixRoomResult = await federationSDK.createRoom(matrixUserId, roomName, room.t === 'c' ? 'public' : 'invite'); + // Add runtime attributes after Matrix room is created + addSpanAttributes({ + matrixRoomId: matrixRoomResult.room_id, + matrixEventId: matrixRoomResult.event_id, + matrixUserId, + visibility: room.t === 'c' ? 'public' : 'invite', + }); + this.logger.debug({ msg: 'Matrix room created', response: matrixRoomResult }); await Rooms.setAsFederated(room._id, { mrid: matrixRoomResult.room_id, origin: this.serverName }); @@ -127,6 +148,9 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } } + @traced((usernames: string[]) => ({ + usernameCount: usernames?.length, + })) async ensureFederatedUsersExistLocally(usernames: string[]): Promise { try { this.logger.debug({ msg: 'Ensuring federated users exist locally before DM creation', memberCount: usernames.length }); @@ -150,6 +174,11 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } } + @traced((room: IRoom, members: IUser[], creatorId: IUser['_id']) => ({ + roomId: room?._id, + memberCount: members?.length, + creatorId, + })) async createDirectMessageRoom(room: IRoom, members: IUser[], creatorId: IUser['_id']): Promise { try { this.logger.debug({ msg: 'Creating direct message room in Matrix', roomId: room._id, memberCount: members.length }); @@ -166,6 +195,12 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS .map((member) => userIdSchema.parse(isUserNativeFederated(member) ? member.username : `@${member.username}:${this.serverName}`)), }); + // Add resulting Matrix room ID + addSpanAttributes({ + matrixRoomId: roomId, + creatorUsername: creator.username, + }); + await Rooms.setAsFederated(room._id, { mrid: roomId, origin: this.serverName, @@ -306,12 +341,30 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS }; } + @traced((message: IMessage, room: IRoomNativeFederated, user: IUser) => ({ + messageId: message?._id, + roomId: room?._id, + matrixRoomId: room?.federation?.mrid, + userId: user?._id, + username: user?.username, + hasFiles: Boolean(message?.files?.length), + hasThread: Boolean(message?.tmid), + hasAttachments: Boolean(message?.attachments?.length), + })) async sendMessage(message: IMessage, room: IRoomNativeFederated, user: IUser): Promise { try { const userMui = isUserNativeFederated(user) ? user.federation.mui : `@${user.username}:${this.serverName}`; + const messageType = message.files && message.files.length > 0 ? 'file' : 'text'; + + // Add runtime attributes for computed values + addSpanAttributes({ + matrixUserId: userMui, + messageType, + isNativeFederatedUser: isUserNativeFederated(user), + }); let result; - if (message.files && message.files.length > 0) { + if (messageType === 'file') { result = await this.handleFileMessage(message, room.federation.mrid, userMui, this.serverName); } else { result = await this.handleTextMessage(message, room.federation.mrid, userMui, this.serverName); @@ -321,6 +374,11 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS throw new Error('Failed to send message to Matrix - no result returned'); } + // Add the resulting event ID + addSpanAttributes({ + matrixEventId: result.eventId, + }); + await Messages.setFederationEventIdById(message._id, result.eventId); this.logger.debug({ msg: 'Message sent to Matrix successfully', eventId: result.eventId }); @@ -370,6 +428,11 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS }; } + @traced((matrixRoomId: string, message: IMessage) => ({ + matrixRoomId, + messageId: message?._id, + federationEventId: message?.federation?.eventId, + })) async deleteMessage(matrixRoomId: string, message: IMessage): Promise { try { if (!isMessageFromMatrixFederation(message) || isDeletedMessage(message)) { @@ -392,6 +455,12 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } } + @traced((room: IRoomNativeFederated, matrixUsersUsername: string[], inviter: IUser) => ({ + roomId: room?._id, + matrixRoomId: room?.federation?.mrid, + inviteeCount: matrixUsersUsername?.length, + inviterUsername: inviter?.username, + })) async inviteUsersToRoom(room: IRoomNativeFederated, matrixUsersUsername: string[], inviter: IUser): Promise { try { const inviterUserId = `@${inviter.username}:${this.serverName}`; @@ -426,6 +495,11 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } } + @traced((messageId: string, reaction: string, user: IUser) => ({ + messageId, + reaction, + username: user?.username, + })) async sendReaction(messageId: string, reaction: string, user: IUser): Promise { try { const message = await Messages.findOneById(messageId); @@ -447,6 +521,15 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS const userMui = isUserNativeFederated(user) ? user.federation.mui : `@${user.username}:${this.serverName}`; + // Add runtime attributes after querying message and room + addSpanAttributes({ + roomId: room._id, + matrixRoomId: room.federation.mrid, + targetEventId: matrixEventId, + reactionKey, + matrixUserId: userMui, + }); + const eventId = await federationSDK.sendReaction( roomIdSchema.parse(room.federation.mrid), eventIdSchema.parse(matrixEventId), @@ -454,6 +537,11 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS userIdSchema.parse(userMui), ); + // Add resulting event ID + addSpanAttributes({ + reactionEventId: eventId, + }); + await Messages.setFederationReactionEventId(user.username || '', messageId, reaction, eventId); this.logger.debug({ eventId, msg: 'Reaction sent to Matrix successfully' }); @@ -463,6 +551,11 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } } + @traced((messageId: string, reaction: string, user: IUser, _oldMessage: IMessage) => ({ + messageId, + reaction, + username: user?.username, + })) async removeReaction(messageId: string, reaction: string, user: IUser, oldMessage: IMessage): Promise { try { const message = await Messages.findOneById(messageId); @@ -517,13 +610,23 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } } + @traced((eventId: EventID) => ({ + eventId, + })) async getEventById(eventId: EventID) { return federationSDK.getEventById(eventId); } + @traced((roomId: string, user: IUser, kicker?: IUser) => ({ + roomId, + username: user?.username, + hasKicker: Boolean(kicker), + kickerUsername: kicker?.username, + })) async leaveRoom(roomId: string, user: IUser, kicker?: IUser): Promise { if (kicker && isUserNativeFederated(kicker)) { this.logger.debug('Only local users can remove others, ignoring action'); + addSpanAttributes({ skipped: true, reason: 'kicker_is_native_federated' }); return; } @@ -531,11 +634,19 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS const room = await Rooms.findOneById(roomId); if (!room || !isRoomNativeFederated(room)) { this.logger.debug({ msg: 'Room is not federated, skipping leave operation', roomId }); + addSpanAttributes({ skipped: true, reason: 'room_not_federated' }); return; } const actualMatrixUserId = isUserNativeFederated(user) ? user.federation.mui : `@${user.username}:${this.serverName}`; + // Add runtime attributes + addSpanAttributes({ + matrixRoomId: room.federation.mrid, + matrixUserId: actualMatrixUserId, + isNativeFederatedUser: isUserNativeFederated(user), + }); + await federationSDK.leaveRoom(roomIdSchema.parse(room.federation.mrid), userIdSchema.parse(actualMatrixUserId)); this.logger.info({ msg: 'User left Matrix room successfully', username: user.username, roomId: room.federation.mrid }); @@ -545,6 +656,12 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } } + @traced((room: IRoomNativeFederated, removedUser: IUser, userWhoRemoved: IUser) => ({ + roomId: room?._id, + matrixRoomId: room?.federation?.mrid, + removedUsername: removedUser?.username, + kickerUsername: userWhoRemoved?.username, + })) async kickUser(room: IRoomNativeFederated, removedUser: IUser, userWhoRemoved: IUser): Promise { try { const actualKickedMatrixUserId = isUserNativeFederated(removedUser) @@ -555,6 +672,14 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS ? userWhoRemoved.federation.mui : `@${userWhoRemoved.username}:${this.serverName}`; + // Add runtime attributes for computed Matrix user IDs + addSpanAttributes({ + kickedMatrixUserId: actualKickedMatrixUserId, + senderMatrixUserId: actualSenderMatrixUserId, + kickedIsNativeFederated: isUserNativeFederated(removedUser), + senderIsNativeFederated: isUserNativeFederated(userWhoRemoved), + }); + await federationSDK.kickUser( roomIdSchema.parse(room.federation.mrid), userIdSchema.parse(actualKickedMatrixUserId), @@ -574,6 +699,12 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } } + @traced((room: IRoomNativeFederated, message: IMessage) => ({ + roomId: room?._id, + matrixRoomId: room?.federation?.mrid, + messageId: message?._id, + federationEventId: message?.federation?.eventId, + })) async updateMessage(room: IRoomNativeFederated, message: IMessage): Promise { try { const matrixEventId = message.federation?.eventId; @@ -609,6 +740,11 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS } } + @traced((rid: string, displayName: string, user: IUser) => ({ + roomId: rid, + newName: displayName, + username: user?.username, + })) async updateRoomName(rid: string, displayName: string, user: IUser): Promise { const room = await Rooms.findOneById(rid); if (!room || !isRoomNativeFederated(room)) { @@ -625,6 +761,12 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS await federationSDK.updateRoomName(roomIdSchema.parse(room.federation.mrid), displayName, userIdSchema.parse(userMui)); } + @traced((room: IRoomNativeFederated, topic: string, user: Pick) => ({ + roomId: room?._id, + matrixRoomId: room?.federation?.mrid, + topic, + username: user?.username, + })) async updateRoomTopic( room: IRoomNativeFederated, topic: string, @@ -640,6 +782,13 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS await federationSDK.setRoomTopic(roomIdSchema.parse(room.federation.mrid), userIdSchema.parse(userMui), topic); } + @traced((room: IRoomNativeFederated, senderId: string, userId: string, role: string) => ({ + roomId: room?._id, + matrixRoomId: room?.federation?.mrid, + senderId, + targetUserId: userId, + role, + })) async addUserRoleRoomScoped( room: IRoomNativeFederated, senderId: string, @@ -682,6 +831,11 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS ); } + @traced((rid: string, user: string, isTyping: boolean) => ({ + roomId: rid, + username: user, + isTyping, + })) async notifyUserTyping(rid: string, user: string, isTyping: boolean) { if (!this.processEDUTyping) { return; @@ -712,6 +866,9 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS void federationSDK.sendTypingNotification(room.federation.mrid, userMui, isTyping); } + @traced((matrixIds: string[]) => ({ + matrixIdCount: matrixIds?.length, + })) async verifyMatrixIds(matrixIds: string[]): Promise<{ [key: string]: string }> { const results = Object.fromEntries( await Promise.all( @@ -759,6 +916,11 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS return results; } + @traced((roomId: IRoom['_id'], userId: IUser['_id'], action: 'accept' | 'reject') => ({ + roomId, + userId, + action, + })) async handleInvite(roomId: IRoom['_id'], userId: IUser['_id'], action: 'accept' | 'reject'): Promise { const subscription = await Subscriptions.findInvitedSubscription(roomId, userId); if (!subscription) { @@ -782,6 +944,14 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS // TODO: should use common function to get matrix user ID const matrixUserId = isUserNativeFederated(user) ? user.federation.mui : `@${user.username}:${this.serverName}`; + // Add runtime attributes after querying room and user + addSpanAttributes({ + matrixRoomId: room.federation.mrid, + matrixUserId, + username: user.username, + isNativeFederatedUser: isUserNativeFederated(user), + }); + if (action === 'accept') { await federationSDK.acceptInvite(room.federation.mrid, matrixUserId); } diff --git a/ee/packages/federation-matrix/src/api/_matrix/transactions.ts b/ee/packages/federation-matrix/src/api/_matrix/transactions.ts index c101ffa10270f..fdc5d03f05561 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/transactions.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/transactions.ts @@ -2,6 +2,7 @@ import type { EventID } from '@rocket.chat/federation-sdk'; import { federationSDK } from '@rocket.chat/federation-sdk'; import { Router } from '@rocket.chat/http-router'; import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; +import { addSpanAttributes } from '@rocket.chat/tracing'; import { canAccessResourceMiddleware } from '../middlewares/canAccessResource'; import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated'; @@ -335,6 +336,28 @@ export const getMatrixTransactionsRoutes = () => { async (c) => { const body = await c.req.json(); + // Tag the span with federation operation types for trace filtering + const pdus: Array<{ type: string }> = body.pdus || []; + const edus: Array<{ edu_type: string }> = body.edus || []; + const pduTypes = new Set(pdus.map((p) => p.type)); + const eduTypes = new Set(edus.map((e) => e.edu_type).filter((type): type is string => type !== undefined)); + + addSpanAttributes({ + 'federation.direction': 'incoming', + 'federation.origin': body.origin, + 'federation.pdu_count': pdus.length, + 'federation.edu_count': edus.length, + 'federation.has_message': pduTypes.has('m.room.message'), + 'federation.has_membership': pduTypes.has('m.room.member'), + 'federation.has_reaction': pduTypes.has('m.reaction'), + 'federation.has_redaction': pduTypes.has('m.room.redaction'), + 'federation.has_encrypted': pduTypes.has('m.room.encrypted'), + 'federation.has_typing': eduTypes.has('m.typing'), + 'federation.has_presence': eduTypes.has('m.presence'), + 'federation.pdu_types': Array.from(pduTypes).join(','), + 'federation.edu_types': Array.from(eduTypes).join(','), + }); + try { await federationSDK.processIncomingTransaction(body); } catch (error: any) { diff --git a/ee/packages/federation-matrix/src/api/routes.ts b/ee/packages/federation-matrix/src/api/routes.ts index 986bc4db81b83..2ad6d9e56cdf4 100644 --- a/ee/packages/federation-matrix/src/api/routes.ts +++ b/ee/packages/federation-matrix/src/api/routes.ts @@ -1,4 +1,5 @@ import { Router } from '@rocket.chat/http-router'; +import { tracerSpanMiddleware } from '@rocket.chat/tracing'; import { getWellKnownRoutes } from './.well-known/server'; import { getMatrixInviteRoutes } from './_matrix/invite'; @@ -22,6 +23,7 @@ export const getFederationRoutes = (version: string): { matrix: Router<'/_matrix matrix .use(isFederationEnabledMiddleware) .use(isLicenseEnabledMiddleware) + .use(tracerSpanMiddleware()) .use(getKeyServerRoutes()) .use(getFederationVersionsRoutes(version)) .use(isFederationDomainAllowedMiddleware) diff --git a/ee/packages/federation-matrix/tests/federation-operations-reference.md b/ee/packages/federation-matrix/tests/federation-operations-reference.md new file mode 100644 index 0000000000000..fd7b905591eb7 --- /dev/null +++ b/ee/packages/federation-matrix/tests/federation-operations-reference.md @@ -0,0 +1,161 @@ +# Federation Operations - Complete Reference + +This document provides a comprehensive reference for all incoming and outgoing federation operations in the Rocket.Chat codebase. + +## Operations Rocket.Chat REACTS TO (Incoming from Other Servers) + +| Operation | Event/Endpoint | Handler Location | Description | Triggered By | +| --- | --- | --- | --- | --- | +| **EDU - Typing** | `homeserver.matrix.typing` | `ee/packages/federation-matrix/src/events/edu.ts:10` | Broadcast typing indicator to local room | Matrix transaction (EDU) | +| **EDU - Presence** | `homeserver.matrix.presence` | `ee/packages/federation-matrix/src/events/edu.ts:28` | Update federated user presence status | Matrix transaction (EDU) | +| **Invite - Process** | `PUT /_matrix/federation/v2/invite/:roomId/:eventId` | `ee/packages/federation-matrix/src/api/_matrix/invite.ts:135` | Process incoming room invite from remote server | HTTP endpoint | +| **Member - Invite** | `homeserver.matrix.membership` (invite) | `ee/packages/federation-matrix/src/events/member.ts:120` | Create room/subscription for invited user | Matrix transaction | +| **Member - Join** | `homeserver.matrix.membership` (join) | `ee/packages/federation-matrix/src/events/member.ts:197` | Accept user subscription and update room | Matrix transaction | +| **Member - Leave** | `homeserver.matrix.membership` (leave) | `ee/packages/federation-matrix/src/events/member.ts:229` | Remove user from room | Matrix transaction | +| **Message - Text** | `homeserver.matrix.message` | `ee/packages/federation-matrix/src/events/message.ts:114` | Save incoming text message | Matrix transaction | +| **Message - Edit** | `homeserver.matrix.message` (with `m.replace`) | `ee/packages/federation-matrix/src/events/message.ts:154` | Update existing message content | Matrix transaction | +| **Message - Thread** | `homeserver.matrix.message` (with `m.thread`) | `ee/packages/federation-matrix/src/events/message.ts:142` | Process threaded message with tmid | Matrix transaction | +| **Message - Quote/Reply** | `homeserver.matrix.message` (with `m.in_reply_to`) | `ee/packages/federation-matrix/src/events/message.ts:208` | Process quoted/replied message | Matrix transaction | +| **Message - Media** | `homeserver.matrix.message` (with file types) | `ee/packages/federation-matrix/src/events/message.ts:232` | Download and store file/image/video/audio | Matrix transaction | +| **Message - Encrypted** | `homeserver.matrix.encrypted` | `ee/packages/federation-matrix/src/events/message.ts:267` | Process incoming encrypted message | Matrix transaction | +| **Message - Redaction** | `homeserver.matrix.redaction` (for messages) | `ee/packages/federation-matrix/src/events/message.ts:381` | Delete redacted message | Matrix transaction | +| **Ping** | `homeserver.ping` | `ee/packages/federation-matrix/src/events/ping.ts:4` | Debug ping event (console log) | Matrix transaction | +| **Reaction - Add** | `homeserver.matrix.reaction` | `ee/packages/federation-matrix/src/events/reaction.ts:10` | Add reaction to message | Matrix transaction | +| **Reaction - Remove** | `homeserver.matrix.redaction` (for reactions) | `ee/packages/federation-matrix/src/events/reaction.ts:49` | Remove reaction from message | Matrix transaction | +| **Room - Name Change** | `homeserver.matrix.room.name` | `ee/packages/federation-matrix/src/events/room.ts:8` | Update room name | Matrix transaction | +| **Room - Topic Change** | `homeserver.matrix.room.topic` | `ee/packages/federation-matrix/src/events/room.ts:28` | Update room topic | Matrix transaction | +| **Room - Role Change** | `homeserver.matrix.room.role` | `ee/packages/federation-matrix/src/events/room.ts:53` | Update user power level/role in room | Matrix transaction | + +## Operations Rocket.Chat TRIGGERS (Outgoing to Other Servers) + +| Operation | Method | Location | Description | Called When | +| --- | --- | --- | --- | --- | +| **EDU - Presence** | `sendPresenceUpdateToRooms()` (via event listener) | `ee/packages/federation-matrix/src/FederationMatrix.ts:164` | Send presence update to federated rooms | User presence changed locally | +| **EDU - Typing** | `notifyUserTyping()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:816` | Send typing indicator to Matrix | User typing locally | +| **Event - Get By ID** | `getEventById()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:656` | Retrieve Matrix event by ID | Internal lookup (e.g., for redactions) | +| **Invite - Accept/Reject** | `handleInvite()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:893` | Accept or reject room invite | User accepts/rejects invite | +| **Matrix IDs - Verify** | `verifyMatrixIds()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:846` | Verify Matrix user IDs exist | User verification before invite | +| **Member - Invite** | `inviteUsersToRoom()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:531` | Invite users to federated room | User invited locally | +| **Member - Kick** | `kickUser()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:684` | Kick user from room | User kicked locally | +| **Member - Leave** | `leaveRoom()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:660` | User leaves federated room | User leaves locally | +| **Message - Delete** | `deleteMessage()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:509` | Redact/delete message | Message deleted locally | +| **Message - Quote/Reply** | `sendMessage()` → `handleQuoteMessage()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:432` | Send quoted message | Quoted message sent locally | +| **Message - Send File** | `sendMessage()` → `handleFileMessage()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:326` | Send file/image/video/audio | File message sent locally | +| **Message - Send Text** | `sendMessage()` → `handleTextMessage()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:375` | Send text message | Message sent locally | +| **Message - Thread** | `sendMessage()` → `handleThreadedMessage()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:399` | Send threaded message | Thread message sent locally | +| **Message - Update** | `updateMessage()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:708` | Edit existing message | Message edited locally | +| **Reaction - Add** | `sendReaction()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:565` | Add reaction | Reaction added locally | +| **Reaction - Remove** | `removeReaction()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:602` | Remove reaction | Reaction removed locally | +| **Room - Create** | `createRoom()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:209` | Create federated room | Room created locally | +| **Room - Create DM** | `createDirectMessageRoom()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:259` | Create direct message room | DM created locally | +| **Room - Update Name** | `updateRoomName()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:743` | Change room name | Room name changed locally | +| **Room - Update Role** | `addUserRoleRoomScoped()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:774` | Change user role (power level) | User role changed locally | +| **Room - Update Topic** | `updateRoomTopic()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:759` | Change room topic | Room topic changed locally | +| **User - Ensure Exists** | `ensureFederatedUsersExistLocally()` | `ee/packages/federation-matrix/src/FederationMatrix.ts:236` | Create federated user locally | Before DM/room creation with external users | + +## Callback Hooks (Trigger Points for Outgoing Operations) + +| Callback | Location | Triggers | +| --- | --- | --- | +| `federation.afterCreateFederatedRoom` | `apps/meteor/ee/server/hooks/federation/index.ts:21` | `createRoom()`, `inviteUsersToRoom()` | +| `afterSaveMessage` | `apps/meteor/ee/server/hooks/federation/index.ts:53` | `sendMessage()` | +| `afterSaveMessage` (edit) | `apps/meteor/ee/server/hooks/federation/index.ts:214` | `updateMessage()` | +| `afterDeleteMessage` | `apps/meteor/ee/server/hooks/federation/index.ts:76` | `deleteMessage()` | +| `beforeAddUsersToRoom` | `apps/meteor/ee/server/hooks/federation/index.ts:91` | `ensureFederatedUsersExistLocally()` | +| `beforeAddUserToRoom` | `apps/meteor/ee/server/hooks/federation/index.ts:105` | `inviteUsersToRoom()` | +| `afterSetReaction` | `apps/meteor/ee/server/hooks/federation/index.ts:142` | `sendReaction()` | +| `afterUnsetReaction` | `apps/meteor/ee/server/hooks/federation/index.ts:157` | `removeReaction()` | +| `afterLeaveRoomCallback` | `apps/meteor/ee/server/hooks/federation/index.ts:172` | `leaveRoom()` | +| `afterRemoveFromRoomCallback` | `apps/meteor/ee/server/hooks/federation/index.ts:182` | `kickUser()` | +| `afterRoomNameChange` | `apps/meteor/ee/server/hooks/federation/index.ts:192` | `updateRoomName()` | +| `afterRoomTopicChange` | `apps/meteor/ee/server/hooks/federation/index.ts:203` | `updateRoomTopic()` | +| `beforeChangeRoomRole` | `apps/meteor/ee/server/hooks/federation/index.ts:229` | `addUserRoleRoomScoped()` | +| `beforeCreateDirectRoom` | `apps/meteor/ee/server/hooks/federation/index.ts:239` | `ensureFederatedUsersExistLocally()` | +| `afterCreateDirectRoom` | `apps/meteor/ee/server/hooks/federation/index.ts:264` | `createDirectMessageRoom()` | +| `presence.status` (event) | `ee/packages/federation-matrix/src/FederationMatrix.ts:164` | `sendPresenceUpdateToRooms()` | + +## Summary Statistics + +- **Total Incoming Operations**: 19 (18 event handlers + 1 invite endpoint) +- **Total Outgoing Operations**: 22 (service methods) +- **Entry Point for Incoming**: `PUT /_matrix/federation/v1/send/:txnId` (`ee/packages/federation-matrix/src/api/_matrix/transactions.ts:323`) +- **Entry Point for Outgoing**: Callback hooks in `apps/meteor/ee/server/hooks/federation/index.ts` +- **Event Registration**: `ee/packages/federation-matrix/src/events/index.ts` + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ INCOMING FLOW │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Remote Matrix Server │ +│ │ │ +│ ▼ │ +│ PUT /_matrix/federation/v1/send/:txnId │ +│ (transactions.ts:323) │ +│ │ │ +│ ▼ │ +│ federationSDK.processIncomingTransaction() │ +│ │ │ +│ ▼ │ +│ Event Emitter Service │ +│ │ │ +│ ├──► homeserver.matrix.message ──► message.ts │ +│ ├──► homeserver.matrix.membership ──► member.ts │ +│ ├──► homeserver.matrix.reaction ──► reaction.ts │ +│ ├──► homeserver.matrix.redaction ──► reaction.ts / message.ts │ +│ ├──► homeserver.matrix.room.* ──► room.ts │ +│ ├──► homeserver.matrix.typing ──► edu.ts │ +│ ├──► homeserver.matrix.presence ──► edu.ts │ +│ └──► homeserver.matrix.encrypted ──► message.ts │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ OUTGOING FLOW │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Rocket.Chat Core Action │ +│ │ │ +│ ▼ │ +│ Callback Hooks (apps/meteor/ee/server/hooks/federation/index.ts) │ +│ │ │ +│ ├──► afterSaveMessage ──► FederationMatrix.sendMessage() │ +│ ├──► afterDeleteMessage ──► FederationMatrix.deleteMessage() │ +│ ├──► afterSetReaction ──► FederationMatrix.sendReaction() │ +│ ├──► afterUnsetReaction ──► FederationMatrix.removeReaction() │ +│ ├──► beforeAddUserToRoom ──► FederationMatrix.inviteUsersToRoom()│ +│ ├──► afterLeaveRoomCallback ──► FederationMatrix.leaveRoom() │ +│ ├──► afterRemoveFromRoom ──► FederationMatrix.kickUser() │ +│ ├──► afterRoomNameChange ──► FederationMatrix.updateRoomName() │ +│ ├──► afterRoomTopicChange ──► FederationMatrix.updateRoomTopic() │ +│ └──► beforeChangeRoomRole ──► FederationMatrix.addUserRoleRoomScoped()│ +│ │ +│ ▼ │ +│ FederationMatrix Service (FederationMatrix.ts) │ +│ │ │ +│ ▼ │ +│ federationSDK.* methods │ +│ │ │ +│ ▼ │ +│ Remote Matrix Server │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## Key Files + +| File | Purpose | +| --- | --- | +| `ee/packages/federation-matrix/src/FederationMatrix.ts` | Main service class with all outgoing operation methods | +| `ee/packages/federation-matrix/src/events/index.ts` | Registers all incoming event handlers | +| `ee/packages/federation-matrix/src/events/message.ts` | Handles incoming messages, edits, media, encrypted, redactions | +| `ee/packages/federation-matrix/src/events/member.ts` | Handles membership events (invite, join, leave) | +| `ee/packages/federation-matrix/src/events/reaction.ts` | Handles reactions and reaction redactions | +| `ee/packages/federation-matrix/src/events/room.ts` | Handles room state changes (name, topic, roles) | +| `ee/packages/federation-matrix/src/events/edu.ts` | Handles ephemeral data (typing, presence) | +| `ee/packages/federation-matrix/src/api/_matrix/transactions.ts` | Main transaction endpoint for incoming PDUs/EDUs | +| `ee/packages/federation-matrix/src/api/_matrix/invite.ts` | Invite processing endpoint | +| `apps/meteor/ee/server/hooks/federation/index.ts` | All callback hooks connecting RC core to federation | + diff --git a/ee/packages/federation-matrix/tests/scripts/run-integration-tests.sh b/ee/packages/federation-matrix/tests/scripts/run-integration-tests.sh index 64fe8b066e6a5..b5cb05b84c30b 100755 --- a/ee/packages/federation-matrix/tests/scripts/run-integration-tests.sh +++ b/ee/packages/federation-matrix/tests/scripts/run-integration-tests.sh @@ -36,7 +36,7 @@ NO_TEST=false CI=false LOGS=false START_CONTAINERS=true - +OBSERVABILITY_ENABLED=false while [[ $# -gt 0 ]]; do case $1 in @@ -69,6 +69,12 @@ while [[ $# -gt 0 ]]; do NO_TEST=true shift ;; + --observability) + OBSERVABILITY_ENABLED=true + KEEP_RUNNING=true # Automatically keep running when observability is enabled + INCLUDE_ELEMENT=true # Automatically include Element when observability is enabled + shift + ;; --image) USE_PREBUILT_IMAGE=true # If no IMAGE value is provided (or next token is another flag), default to latest @@ -89,6 +95,7 @@ while [[ $# -gt 0 ]]; do echo " --keep-running Keep Docker containers running after tests complete" echo " --element Include Element web client in the test environment" echo " --no-test Start containers and skip running tests" + echo " --observability Enable observability services (Grafana, Prometheus, Tempo) and keep them running. Automatically includes Element web client." echo " --image [IMAGE] Use a pre-built Docker image instead of building locally" echo " --help, -h Show this help message" echo "" @@ -97,6 +104,7 @@ while [[ $# -gt 0 ]]; do echo "If --image is provided without a value, defaults to rocketchat/rocket.chat:latest" echo "Use --element to run all services including Element web client" echo "Use --no-test to start containers and skip running tests" + echo "Use --observability to enable tracing/metrics (combines well with --start-containers-only)" exit 0 ;; *) @@ -107,6 +115,15 @@ while [[ $# -gt 0 ]]; do esac done +# Determine the compose profile to use +if [ "$OBSERVABILITY_ENABLED" = true ]; then + COMPOSE_PROFILE="observability" +elif [ "$INCLUDE_ELEMENT" = true ]; then + COMPOSE_PROFILE="element" +else + COMPOSE_PROFILE="test" +fi + # Logging functions log_info() { echo -e "${BLUE}ℹ️ [$(date '+%Y-%m-%d %H:%M:%S')] $1${NC}" @@ -164,9 +181,14 @@ cleanup() { log_info " - Rocket.Chat: https://rc1" log_info " - Synapse: https://hs1" log_info " - MongoDB: localhost:27017" - if [ "$INCLUDE_ELEMENT" = true ]; then + if [ "$INCLUDE_ELEMENT" = true ] || [ "$OBSERVABILITY_ENABLED" = true ]; then log_info " - Element: https://element" fi + if [ "$OBSERVABILITY_ENABLED" = true ]; then + log_info " - Grafana: http://localhost:4001" + log_info " - Prometheus: http://localhost:9090" + log_info " - Tempo: http://localhost:3200" + fi log_info "To stop containers manually, run: docker compose -f \"$DOCKER_COMPOSE_FILE\" --profile \"$COMPOSE_PROFILE\" down -v" else log_info "Cleaning up services..." @@ -181,6 +203,11 @@ cleanup() { rm -rf "$BUILD_DIR" || true fi + # Restore .yarnrc.yml from backup if it exists (in case of early exit) + if [ -n "${ROCKETCHAT_ROOT:-}" ] && [ -f "$ROCKETCHAT_ROOT/.yarnrc.yml.bak" ]; then + mv "$ROCKETCHAT_ROOT/.yarnrc.yml.bak" "$ROCKETCHAT_ROOT/.yarnrc.yml" || true + fi + # Exit with the test result code if [ -n "${TEST_EXIT_CODE:-}" ]; then exit $TEST_EXIT_CODE @@ -199,12 +226,6 @@ if [ ! -f "$DOCKER_COMPOSE_FILE" ]; then exit 1 fi -if [ "$INCLUDE_ELEMENT" = true ]; then - COMPOSE_PROFILE="element" -else - COMPOSE_PROFILE="test" -fi - if [ "$START_CONTAINERS" = true ]; then # Build Rocket.Chat locally if not using pre-built image if [ "$USE_PREBUILT_IMAGE" = false ]; then @@ -215,9 +236,16 @@ if [ "$START_CONTAINERS" = true ]; then log_info "Cleaning up previous build..." rm -rf "$BUILD_DIR" + # Configure yarn for cross-platform builds (needed for sharp and other native modules) + # This adds support for linux and darwin (macOS) on arm64/x64 with glibc and musl + log_info "Configuring yarn for cross-platform builds..." + cd "$ROCKETCHAT_ROOT" + cp .yarnrc.yml .yarnrc.yml.bak + yarn config set supportedArchitectures --json '{"os": ["linux", "darwin"], "cpu": ["arm64", "x64"], "libc": ["glibc", "musl"]}' + yarn install + # Build the project log_info "Building packages from project root..." - cd "$ROCKETCHAT_ROOT" yarn build # Build the Meteor bundle (must be run from the meteor directory) @@ -225,6 +253,12 @@ if [ "$START_CONTAINERS" = true ]; then cd "$ROCKETCHAT_ROOT/apps/meteor" METEOR_DISABLE_OPTIMISTIC_CACHING=1 meteor build --server-only --directory "$BUILD_DIR" + # Restore .yarnrc.yml after build completes + if [ -f "$ROCKETCHAT_ROOT/.yarnrc.yml.bak" ]; then + log_info "Restoring original yarn configuration..." + mv "$ROCKETCHAT_ROOT/.yarnrc.yml.bak" "$ROCKETCHAT_ROOT/.yarnrc.yml" + fi + log_success "Build completed!" else log_info "🚀 Using pre-built image: $PREBUILT_IMAGE" @@ -248,7 +282,9 @@ if [ "$START_CONTAINERS" = true ]; then fi # Start services - if [ "$INCLUDE_ELEMENT" = true ]; then + if [ "$OBSERVABILITY_ENABLED" = true ]; then + log_info "Starting federation services with observability (includes Element, Grafana, Prometheus, Tempo)..." + elif [ "$INCLUDE_ELEMENT" = true ]; then log_info "Starting all federation services including Element web client..." else log_info "Starting federation services (test profile only)..." @@ -366,11 +402,16 @@ else log_info " - Access Rocket.Chat at: https://rc1" log_info " - Access Synapse at: https://hs1" log_info " - Access MongoDB at: localhost:27017" - if [ "$INCLUDE_ELEMENT" = true ]; then + if [ "$INCLUDE_ELEMENT" = true ] || [ "$OBSERVABILITY_ENABLED" = true ]; then log_info " - Access Element at: https://element" fi + if [ "$OBSERVABILITY_ENABLED" = true ]; then + log_info " - Access Grafana at: http://localhost:4001" + log_info " - Access Prometheus at: http://localhost:9090" + log_info " - Access Tempo at: http://localhost:3200" + fi log_info "" - log_info "To run tests manually, execute: yarn testend-to-end" + log_info "To run tests manually, execute: yarn test:end-to-end" log_info "To stop containers, use: docker compose -f $DOCKER_COMPOSE_FILE down" TEST_EXIT_CODE=0 fi diff --git a/packages/models/src/models/BaseRaw.ts b/packages/models/src/models/BaseRaw.ts index 633e9fad3f3bc..28ad0dbd267d4 100644 --- a/packages/models/src/models/BaseRaw.ts +++ b/packages/models/src/models/BaseRaw.ts @@ -1,6 +1,6 @@ import type { RocketChatRecordDeleted } from '@rocket.chat/core-typings'; import type { IBaseModel, DefaultFields, ResultFields, FindPaginated, InsertionModel } from '@rocket.chat/model-typings'; -import { traceInstanceMethods } from '@rocket.chat/tracing'; +import { tracedClass } from '@rocket.chat/tracing'; import { ObjectId } from 'mongodb'; import type { BulkWriteOptions, @@ -46,6 +46,7 @@ type ModelOptions = { collection?: CollectionOptions; }; +@tracedClass({ type: 'model' }) export abstract class BaseRaw< T extends { _id: string }, C extends DefaultFields = undefined, @@ -82,8 +83,6 @@ export abstract class BaseRaw< void this.createIndexes(); this.preventSetUpdatedAt = options?.preventSetUpdatedAt ?? false; - - return traceInstanceMethods(this); } private pendingIndexes: Promise | undefined; diff --git a/packages/tracing/package.json b/packages/tracing/package.json index 5cd396e12725b..c816a0f6a4b9d 100644 --- a/packages/tracing/package.json +++ b/packages/tracing/package.json @@ -17,7 +17,8 @@ "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-grpc": "^0.54.2", - "@opentelemetry/sdk-node": "^0.54.2" + "@opentelemetry/sdk-node": "^0.54.2", + "hono": "^4.10.7" }, "devDependencies": { "@types/jest": "~30.0.0", diff --git a/packages/tracing/src/index.ts b/packages/tracing/src/index.ts index 1f837984f2f5b..5b13db4785a91 100644 --- a/packages/tracing/src/index.ts +++ b/packages/tracing/src/index.ts @@ -9,7 +9,8 @@ import { initDatabaseTracing } from './traceDatabaseCalls'; let tracer: Tracer | undefined; -export * from './traceInstanceMethods'; +export * from './tracingDecorators'; +export * from './middlewares/tracerSpanMiddleware'; export function isTracingEnabled() { return ['yes', 'true'].includes(String(process.env.TRACING_ENABLED).toLowerCase()); @@ -108,3 +109,28 @@ export function injectCurrentContext() { propagation.inject(context.active(), output); return output; } + +/** + * Add attributes to the currently active span. + * Use this inside methods to add runtime information discovered during execution, + * such as computed values, data fetched from DB, or other contextual info. + * + * @param attributes - Key-value pairs to add to the current span + * + * @example + * async sendMessage(message, room, user) { + * const result = await this.handleTextMessage(...); + * + * // Add runtime info after we have it + * addSpanAttributes({ + * matrixEventId: result?.eventId, + * messageType: message.files?.length ? 'file' : 'text', + * }); + * } + */ +export function addSpanAttributes(attributes: Record): void { + const span = trace.getActiveSpan(); + if (span) { + span.setAttributes(attributes); + } +} diff --git a/packages/tracing/src/middlewares/tracerSpanMiddleware.ts b/packages/tracing/src/middlewares/tracerSpanMiddleware.ts new file mode 100644 index 0000000000000..7365c4d87171c --- /dev/null +++ b/packages/tracing/src/middlewares/tracerSpanMiddleware.ts @@ -0,0 +1,43 @@ +import type { MiddlewareHandler } from 'hono'; + +import { tracerSpan } from '../index'; + +/** + * Generic tracing middleware for Hono-based HTTP routers. + * Creates a span for each HTTP request. + * + * @returns Hono middleware handler + * + * @example + * router.use(tracerSpanMiddleware()); + */ +export function tracerSpanMiddleware(): MiddlewareHandler { + return async (c, next) => { + const attributes: Record = { + url: c.req.url, + method: c.req.method, + }; + + // Try to get userId from Hono context if available (for regular API routes) + const userId = c.get('userId') ?? c.get('user')?.id; + if (userId) { + attributes.userId = userId; + } + + return tracerSpan( + `${c.req.method} ${c.req.url}`, + { + attributes, + }, + async (span) => { + if (span) { + c.header('X-Trace-Id', span.spanContext().traceId); + } + + await next(); + + span?.setAttribute('status', c.res.status); + }, + ); + }; +} diff --git a/packages/tracing/src/traceInstanceMethods.ts b/packages/tracing/src/traceInstanceMethods.ts deleted file mode 100644 index 93fa1b1453060..0000000000000 --- a/packages/tracing/src/traceInstanceMethods.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { tracerActiveSpan } from '.'; - -const getArguments = (args: any[]): any[] => { - return args.map((arg) => { - if (typeof arg === 'object' && arg != null && 'session' in arg) { - return '[mongo options with session]'; - } - return arg; - }); -}; - -export function traceInstanceMethods(instance: T, ignoreMethods: string[] = []): T { - const className = instance.constructor.name; - - return new Proxy(instance, { - get(target: Record, prop: string): any { - if (typeof target[prop] === 'function' && !ignoreMethods.includes(prop)) { - return new Proxy(target[prop], { - apply: (target, thisArg, argumentsList): any => { - if (['doNotMixInclusionAndExclusionFields', 'ensureDefaultFields'].includes(prop)) { - return Reflect.apply(target, thisArg, argumentsList); - } - - return tracerActiveSpan( - `model ${className}.${prop}`, - { - attributes: { - model: className, - method: prop, - parameters: getArguments(argumentsList), - }, - }, - () => { - return Reflect.apply(target, thisArg, argumentsList); - }, - ); - }, - }); - } - - return Reflect.get(target, prop); - }, - }) as T; -} diff --git a/packages/tracing/src/tracingDecorators.ts b/packages/tracing/src/tracingDecorators.ts new file mode 100644 index 0000000000000..83eb5e79e3a1a --- /dev/null +++ b/packages/tracing/src/tracingDecorators.ts @@ -0,0 +1,164 @@ +import { tracerActiveSpan } from '.'; + +/** + * Symbol key used to store the attribute extractor on methods + */ +export const TRACE_EXTRACTOR_KEY = Symbol('traceExtractor'); + +/** + * Type for the extractor function stored on decorated methods + */ +export type TraceExtractor = (...args: TArgs) => Record; + +/** + * Interface for methods that have a trace extractor attached + */ +// eslint-disable-next-line @typescript-eslint/ban-types +export interface ITracedMethod extends Function { + [TRACE_EXTRACTOR_KEY]?: TraceExtractor; +} + +/** + * Decorator that attaches an attribute extractor to a method for tracing. + * The extractor receives the method arguments and returns attributes to add to the span. + * + * Use this decorator on methods to define inline attribute extraction that + * will be picked up by `@tracedClass`. + * + * @param extractor - Function that extracts trace attributes from method arguments + * + * @example + * @tracedClass({ type: 'service' }) + * class FederationMatrix { + * @traced((room: IRoom, owner: IUser) => ({ + * roomId: room?._id, + * roomName: room?.name || room?.fname, + * ownerId: owner?._id, + * })) + * async createRoom(room: IRoom, owner: IUser) { + * // method implementation + * } + * } + */ +export function traced(extractor: (...args: TArgs) => Record): MethodDecorator { + return (_target, _propertyKey, descriptor: PropertyDescriptor) => { + const originalMethod = descriptor.value as ITracedMethod; + if (originalMethod) { + originalMethod[TRACE_EXTRACTOR_KEY] = extractor as TraceExtractor; + } + return descriptor; + }; +} + +/** + * Get the trace extractor from a method, if one was attached via @traced decorator. + */ +function getTraceExtractor(method: unknown): TraceExtractor | undefined { + if (typeof method === 'function') { + return (method as ITracedMethod)[TRACE_EXTRACTOR_KEY]; + } + return undefined; +} + +/** + * Options for @tracedClass decorator + */ +export interface ITracedClassOptions { + /** + * The type prefix for span names (e.g., 'model', 'service', 'handler') + */ + type: string; + + /** + * Array of method names to exclude from tracing + */ + ignoreMethods?: string[]; +} + +/** + * Wraps all methods of an instance with OpenTelemetry tracing spans. + * Used internally by @tracedClass decorator. + */ +function traceInstanceMethods(instance: T, options: ITracedClassOptions): T { + const className = instance.constructor.name; + + const { type, ignoreMethods = [] } = options; + + return new Proxy(instance, { + get(target: Record, prop: string): any { + if (typeof target[prop] === 'function' && !ignoreMethods.includes(prop)) { + return new Proxy(target[prop], { + apply: (target, thisArg, argumentsList): any => { + if (['doNotMixInclusionAndExclusionFields', 'ensureDefaultFields'].includes(prop)) { + return Reflect.apply(target, thisArg, argumentsList); + } + + const attributes: Record = { + [type]: className, + method: prop, + }; + + const extractor = getTraceExtractor(target); + + if (extractor) { + try { + const extractedAttrs = extractor(...(argumentsList as unknown[])); + Object.assign(attributes, extractedAttrs); + } catch { + // If extractor fails, continue with base attributes + } + } + + return tracerActiveSpan( + `${type} ${className}.${prop}`, + { attributes: attributes as Record }, + () => { + return Reflect.apply(target, thisArg, argumentsList); + }, + ); + }, + }); + } + + return Reflect.get(target, prop); + }, + }) as T; +} + +/** + * Class decorator that automatically wraps all methods with OpenTelemetry tracing spans. + * + * @param options - Configuration options for tracing + * + * @example + * @tracedClass({ type: 'service' }) + * class FederationMatrix extends ServiceClass { + * @traced((room: IRoom, owner: IUser) => ({ roomId: room?._id })) + * async createRoom(room: IRoom, owner: IUser) { ... } + * } + * + * @example + * @tracedClass({ type: 'model' }) + * class UsersRaw extends BaseRaw { ... } + */ +// eslint-disable-next-line @typescript-eslint/ban-types +export function tracedClass(options: ITracedClassOptions): (target: TFunction) => TFunction { + // eslint-disable-next-line @typescript-eslint/ban-types + return (target: TFunction): TFunction => { + // Use a wrapper function that preserves `new.target` for proper inheritance + const newConstructor = function (this: any, ...args: any[]) { + // Reflect.construct properly handles inheritance by using new.target + // This ensures subclasses work correctly when extending a decorated class + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - new.target is valid in constructor functions + const instance = Reflect.construct(target as any, args, new.target || newConstructor); + return traceInstanceMethods(instance, options); + }; + + newConstructor.prototype = target.prototype; + Object.setPrototypeOf(newConstructor, target); + Object.defineProperty(newConstructor, 'name', { value: target.name }); + + return newConstructor as unknown as TFunction; + }; +} diff --git a/packages/tsconfig/base.json b/packages/tsconfig/base.json index d0936655f05b3..6920336aec2ac 100644 --- a/packages/tsconfig/base.json +++ b/packages/tsconfig/base.json @@ -4,6 +4,8 @@ "target": "es5", "module": "commonjs", + "experimentalDecorators": true, + "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, diff --git a/yarn.lock b/yarn.lock index e0ad7caa22506..3938e7869c550 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8478,6 +8478,7 @@ __metadata: "@rocket.chat/models": "workspace:^" "@rocket.chat/network-broker": "workspace:^" "@rocket.chat/rest-typings": "workspace:^" + "@rocket.chat/tracing": "workspace:^" "@types/emojione": "npm:^2.2.9" "@types/node": "npm:~22.16.5" "@types/sanitize-html": "npm:~2.16.0" @@ -10124,6 +10125,7 @@ __metadata: "@opentelemetry/sdk-node": "npm:^0.54.2" "@types/jest": "npm:~30.0.0" eslint: "npm:~8.45.0" + hono: "npm:^4.10.7" jest: "npm:~30.2.0" ts-jest: "npm:~29.4.5" typescript: "npm:~5.9.3"