diff --git a/fluxer_api/src/api/rpc/RpcService.ts b/fluxer_api/src/api/rpc/RpcService.ts index 494b10bcf..e17224d59 100644 --- a/fluxer_api/src/api/rpc/RpcService.ts +++ b/fluxer_api/src/api/rpc/RpcService.ts @@ -4,7 +4,12 @@ import {createHash} from 'node:crypto'; import {AUTOMATIC_VOICE_REGION_ID, ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants'; import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata'; import {MAX_PRIVATE_CHANNELS_PER_USER} from '@fluxer/constants/src/LimitConstants'; -import {GroupDmAddPermissionFlags, IncomingCallFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants'; +import { + GroupDmAddPermissionFlags, + IncomingCallFlags, + UserFlags, + UserPremiumTypes, +} from '@fluxer/constants/src/UserConstants'; import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError'; import {UnauthorizedError} from '@fluxer/errors/src/domains/core/UnauthorizedError'; import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError'; @@ -1189,6 +1194,9 @@ export class RpcService { version, }; timings.record('build_session_response_payload', responseBuildStartedAtNs, responseBuildSteps); + if ((user.flags & UserFlags.STAFF) === 0n) { + return responsePayload; + } return { _timings: timings.finalize(), ...responsePayload, diff --git a/fluxer_api/src/api/rpc/tests/RpcSessionTimingsVisibility.test.ts b/fluxer_api/src/api/rpc/tests/RpcSessionTimingsVisibility.test.ts new file mode 100644 index 000000000..96afb5bd4 --- /dev/null +++ b/fluxer_api/src/api/rpc/tests/RpcSessionTimingsVisibility.test.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {UserFlags} from '@fluxer/constants/src/UserConstants'; +import {afterEach, beforeEach, describe, expect, test} from 'vitest'; +import {createTestAccount} from '../../auth/tests/AuthTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; +import {HTTP_STATUS} from '../../test/TestConstants'; +import {createBuilder} from '../../test/TestRequestBuilder'; + +interface RpcSessionResponse { + type: 'session'; + data: { + _timings?: { + pod_name?: string; + }; + user: { + id: string; + is_staff: boolean; + }; + }; +} + +async function setUserFlags(harness: ApiTestHarness, userId: string, flags: bigint): Promise { + await createBuilder(harness, '') + .patch(`/test/users/${userId}/flags`) + .body({flags: flags.toString()}) + .expect(HTTP_STATUS.OK) + .execute(); +} + +async function initSession(harness: ApiTestHarness, token: string): Promise { + return await createBuilder(harness, '') + .post('/test/rpc-session-init') + .body({type: 'session', token, version: 1, ip: '127.0.0.1'}) + .expect(HTTP_STATUS.OK) + .execute(); +} + +describe('RpcService session timings visibility', () => { + let harness: ApiTestHarness; + beforeEach(async () => { + harness = await createApiTestHarness(); + }); + afterEach(async () => { + await harness?.shutdown(); + }); + test('omits _timings for a non-staff session', async () => { + const account = await createTestAccount(harness); + await setUserFlags(harness, account.userId, 0n); + const response = await initSession(harness, account.token); + expect(response.data.user.is_staff).toBe(false); + expect(Object.hasOwn(response.data, '_timings')).toBe(false); + }); + test('includes _timings with pod metadata for a staff session', async () => { + const account = await createTestAccount(harness); + await setUserFlags(harness, account.userId, UserFlags.STAFF); + const response = await initSession(harness, account.token); + expect(response.data.user.is_staff).toBe(true); + expect(typeof response.data._timings?.pod_name).toBe('string'); + }); +}); diff --git a/fluxer_gateway/src/gateway/gateway_handler_resume.erl b/fluxer_gateway/src/gateway/gateway_handler_resume.erl index 479289cfa..d3bf5d45c 100644 --- a/fluxer_gateway/src/gateway/gateway_handler_resume.erl +++ b/fluxer_gateway/src/gateway/gateway_handler_resume.erl @@ -154,7 +154,7 @@ finalize_resume(Pid, Seq, MissedEvents, GwTimings0, State) -> ReplayStartedAt, GwTimings0 ), - ResumedData = put_resumed_gateway_timings(GwTimings), + ResumedData = put_resumed_gateway_timings(session_is_staff(Pid), GwTimings), SocketPid ! {dispatch, resumed, ResumedData, Seq}, erlang:garbage_collect(self(), [{type, major}]), {ok, State#{ @@ -209,9 +209,20 @@ remote_trace_meta(Operation, NodeName) -> _ -> #{} end. --spec put_resumed_gateway_timings(gateway_timings:recorder()) -> map(). -put_resumed_gateway_timings(GwTimings) -> - #{<<"_timings_gw">> => gateway_timings_payload:finalize(GwTimings)}. +-spec session_is_staff(pid()) -> boolean(). +session_is_staff(Pid) -> + try gen_server:call(Pid, {is_staff}, 5000) of + IsStaff when is_boolean(IsStaff) -> IsStaff; + _Other -> false + catch + exit:_Reason -> false + end. + +-spec put_resumed_gateway_timings(boolean(), gateway_timings:recorder()) -> map(). +put_resumed_gateway_timings(true, GwTimings) -> + #{<<"_timings_gw">> => gateway_timings_payload:finalize(GwTimings)}; +put_resumed_gateway_timings(false, _GwTimings) -> + #{}. -spec replay_missed_events([term()], pid()) -> ok. replay_missed_events([], _SocketPid) -> diff --git a/fluxer_gateway/src/session/session.erl b/fluxer_gateway/src/session/session.erl index a70acc367..37f319d73 100644 --- a/fluxer_gateway/src/session/session.erl +++ b/fluxer_gateway/src/session/session.erl @@ -96,6 +96,8 @@ init(SessionData) -> {reply, term(), session_state()} | {stop, normal, term(), session_state()}. handle_call({token_verify, Token}, _From, State) when is_binary(Token) -> session_lifecycle:handle_token_verify(Token, State); +handle_call({is_staff}, _From, State) -> + session_lifecycle:handle_is_staff(State); handle_call({heartbeat_ack, Seq}, _From, State) when is_integer(Seq), Seq >= 0 -> session_lifecycle:handle_heartbeat_ack(Seq, State); handle_call({resume, Seq, SocketPid}, _From, State) when diff --git a/fluxer_gateway/src/session/session_lifecycle.erl b/fluxer_gateway/src/session/session_lifecycle.erl index 6d688d1ef..271083a06 100644 --- a/fluxer_gateway/src/session/session_lifecycle.erl +++ b/fluxer_gateway/src/session/session_lifecycle.erl @@ -16,6 +16,7 @@ serialize_state/1, serialize_transfer_state/1, handle_token_verify/2, + handle_is_staff/1, handle_heartbeat_ack/2, handle_resume/3, handle_resume_offline_timeout/2, @@ -268,6 +269,12 @@ token_hash_matches(HashedInput, TokenHash) when token_hash_matches(_HashedInput, _TokenHash) -> false. +-spec handle_is_staff(session_state()) -> {reply, boolean(), session_state()}. +handle_is_staff(#{is_staff := true} = State) -> + {reply, true, State}; +handle_is_staff(State) -> + {reply, false, State}. + -spec handle_heartbeat_ack(seq(), session_state()) -> {reply, boolean(), session_state()}. handle_heartbeat_ack(Seq, #{ack_seq := AckSeq} = State) when Seq < AckSeq -> diff --git a/fluxer_gateway/src/session/session_ready_dispatch.erl b/fluxer_gateway/src/session/session_ready_dispatch.erl index ec284fd01..910c564b6 100644 --- a/fluxer_gateway/src/session/session_ready_dispatch.erl +++ b/fluxer_gateway/src/session/session_ready_dispatch.erl @@ -61,9 +61,9 @@ dispatch_ready_to_socket(State) -> #{children => [PresencesSpan, UsersSpan, BuildSpan]}, GwTimings0 ), - FinalReadyData = FinalReadyData0#{ - <<"_timings_gw">> => gateway_timings_payload:finalize(GwTimings) - }, + FinalReadyData = apply_timings_visibility( + is_staff_session(State), GwTimings, FinalReadyData0 + ), StateWithTimings = gateway_timings:put_state(GwTimings, State), StateAfterReady = dispatch_event(ready, FinalReadyData, StateWithTimings), StateAfterGuilds = dispatch_bot_guild_creates( @@ -79,6 +79,18 @@ dispatch_ready_to_socket(State) -> erlang:garbage_collect(), {noreply, FinalState}. +-spec is_staff_session(session_state()) -> boolean(). +is_staff_session(#{is_staff := true}) -> + true; +is_staff_session(_State) -> + false. + +-spec apply_timings_visibility(boolean(), gateway_timings:recorder(), map()) -> map(). +apply_timings_visibility(true, GwTimings, ReadyData) -> + ReadyData#{<<"_timings_gw">> => gateway_timings_payload:finalize(GwTimings)}; +apply_timings_visibility(false, _GwTimings, ReadyData) -> + maps:without([<<"_timings">>], ReadyData). + -spec build_final_ready_data( session_state(), [map()], diff --git a/fluxer_gateway/test/gateway_resume_timings_tests.erl b/fluxer_gateway/test/gateway_resume_timings_tests.erl index 4f08f7fb0..9283f5a17 100644 --- a/fluxer_gateway/test/gateway_resume_timings_tests.erl +++ b/fluxer_gateway/test/gateway_resume_timings_tests.erl @@ -7,7 +7,7 @@ handle_resume_success_dispatches_gateway_timings_test() -> drain_mailbox(), - SessionPid = spawn(fun() -> fake_resume_session_loop(<<"resume-token">>, 12) end), + SessionPid = spawn(fun() -> fake_resume_session_loop(<<"resume-token">>, 12, true) end), meck:new(session_manager, [passthrough, no_link]), meck:new(rpc_client, [passthrough, no_link]), meck:expect( @@ -36,6 +36,37 @@ handle_resume_success_dispatches_gateway_timings_test() -> meck:unload(session_manager) end. +handle_resume_omits_gateway_timings_for_non_staff_test() -> + drain_mailbox(), + SessionPid = spawn(fun() -> fake_resume_session_loop(<<"resume-token">>, 12, false) end), + meck:new(session_manager, [passthrough, no_link]), + meck:new(rpc_client, [passthrough, no_link]), + meck:expect( + session_manager, + lookup_or_rehydrate, + fun(<<"session-non-staff">>, <<"resume-token">>, SocketPid) when is_pid(SocketPid) -> + {ok, SessionPid} + end + ), + meck:expect(rpc_client, call, fun(_Request) -> error(unexpected_resume_api_call) end), + try + State0 = maps:remove(peer_ip, new_json_state()), + {ok, State1} = gateway_handler_identify:handle_resume( + #{ + <<"token">> => <<"resume-token">>, + <<"session_id">> => <<"session-non-staff">>, + <<"seq">> => 7 + }, + State0 + ), + ?assertEqual(SessionPid, maps:get(session_pid, State1)), + assert_resumed_dispatch_without_timings(12) + after + SessionPid ! stop, + meck:unload(rpc_client), + meck:unload(session_manager) + end. + handle_resume_missing_session_sends_invalid_session_without_api_test() -> drain_mailbox(), meck:new(session_manager, [passthrough, no_link]), @@ -60,6 +91,14 @@ handle_resume_missing_session_sends_invalid_session_without_api_test() -> meck:unload(session_manager) end. +assert_resumed_dispatch_without_timings(ExpectedSeq) -> + receive + {dispatch, resumed, ResumedData, ExpectedSeq} -> + ?assertNot(maps:is_key(<<"_timings_gw">>, ResumedData)) + after 1000 -> + ?assert(false, resumed_not_dispatched) + end. + assert_resumed_timing_dispatch(ExpectedSeq) -> receive {dispatch, resumed, ResumedData, ExpectedSeq} -> @@ -85,14 +124,17 @@ assert_resumed_timing_dispatch(ExpectedSeq) -> ?assert(false, resumed_not_dispatched) end. -fake_resume_session_loop(Token, CurrentSeq) -> +fake_resume_session_loop(Token, CurrentSeq, IsStaff) -> receive {'$gen_call', From, {token_verify, Candidate}} -> gen_server:reply(From, Candidate =:= Token), - fake_resume_session_loop(Token, CurrentSeq); + fake_resume_session_loop(Token, CurrentSeq, IsStaff); {'$gen_call', From, {resume, _Seq, SocketPid}} when is_pid(SocketPid) -> gen_server:reply(From, {ok, [], CurrentSeq}), - fake_resume_session_loop(Token, CurrentSeq); + fake_resume_session_loop(Token, CurrentSeq, IsStaff); + {'$gen_call', From, {is_staff}} -> + gen_server:reply(From, IsStaff), + fake_resume_session_loop(Token, CurrentSeq, IsStaff); stop -> ok after 30000 -> diff --git a/fluxer_gateway/test/session_ready_tests.erl b/fluxer_gateway/test/session_ready_tests.erl index 0f3f76b40..a0f321b34 100644 --- a/fluxer_gateway/test/session_ready_tests.erl +++ b/fluxer_gateway/test/session_ready_tests.erl @@ -264,7 +264,8 @@ dispatch_ready_data_includes_gateway_timings_test() -> #{}, [] ))#{ - gw_timings => GwTimings + gw_timings => GwTimings, + is_staff => true }, {noreply, _State1} = session_ready:dispatch_ready_data(State0), receive @@ -306,6 +307,39 @@ dispatch_ready_data_includes_gateway_timings_test() -> ?assert(false, ready_not_dispatched) end. +dispatch_ready_data_omits_timings_for_non_staff_test() -> + drain_mailbox(), + GwTimings = gateway_timings:record( + test_gateway_step, gateway_timings:start() - 10, gateway_timings:new() + ), + ApiTimings = #{ + <<"unit">> => <<"microseconds">>, + <<"total_us">> => 1234, + <<"pod_name">> => <<"fluxer-api-abc123">> + }, + BaseState = base_ready_state( + <<"session-ready-non-staff-timings-test">>, + 49, + false, + #{}, + [] + ), + State0 = BaseState#{ + gw_timings => GwTimings, + is_staff => false, + ready => (maps:get(ready, BaseState))#{<<"_timings">> => ApiTimings} + }, + {noreply, _State1} = session_ready:dispatch_ready_data(State0), + receive + {dispatch, ready, ReadyData, _ReadySeq} -> + ?assertNot(maps:is_key(<<"_timings_gw">>, ReadyData)), + ?assertNot(maps:is_key(<<"_timings">>, ReadyData)); + OtherReady -> + ?assert(false, {unexpected_ready_message, OtherReady}) + after 1000 -> + ?assert(false, ready_not_dispatched) + end. + -spec find_trace_span(binary(), [map()]) -> map(). find_trace_span(Name, Trace) -> case [Span || Span <- Trace, maps:get(<<"name">>, Span, undefined) =:= Name] of diff --git a/packages/schema/src/domains/rpc/RpcSchemas.ts b/packages/schema/src/domains/rpc/RpcSchemas.ts index 74ddffb4f..d8518bd09 100644 --- a/packages/schema/src/domains/rpc/RpcSchemas.ts +++ b/packages/schema/src/domains/rpc/RpcSchemas.ts @@ -276,8 +276,12 @@ export const RpcSessionTimings = z.object({ export type RpcSessionTimings = z.infer; export const RpcResponseSessionData = z.object({ - _timings: RpcSessionTimings.describe('Structured server-side timings for this session initialization'), - _timings_gw: RpcSessionTimings.optional().describe('Structured gateway-side timings for this session initialization'), + _timings: RpcSessionTimings.optional().describe( + 'Structured server-side timings for this session initialization, sent to staff sessions only', + ), + _timings_gw: RpcSessionTimings.optional().describe( + 'Structured gateway-side timings for this session initialization, sent to staff sessions only', + ), auth_session_id_hash: z.string().nullish().describe('Hash of the authentication session ID'), user: UserPrivateResponse.describe('Private user data for the authenticated user'), user_settings: UserSettingsResponse.nullish().describe('User settings configuration'),