rails-profiler 0.34.0 → 0.34.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b6a0140a91e984bd6d06d7277a149c2a7eeace21ec53c3d3ddc1acb7eb137879
4
- data.tar.gz: 730abdd8332a638e3d78db3d74378ab10fc1280f9d5f83f11b327409b076c1e3
3
+ metadata.gz: b6365d7a34d882d20ca0257247629d6099917508ad78cecc96726dc6788c7a75
4
+ data.tar.gz: e7dffe134ddcac64202aae83bb73963837068ae258b1cdc1d959e78504b31861
5
5
  SHA512:
6
- metadata.gz: '09a3cac5a02d3407f8a03dad19154d8f36c5dd57e8c1f5d9b0021aad015e7e459a4305592e68defb796672a9cbd29b40c48b2933cff56b2fe340c3ae370de767'
7
- data.tar.gz: dda83a3c7086e4679766abe06d0dc5e88befc5e9ebebb19a87ec605a3b268a2ab81fccef0274e8170043bb4c6ab452f68307e5a1546921b55b418289427fc805
6
+ metadata.gz: 8912bad06bc0824942c4e696d9fd408dff3a76238af92faaa7c262815e477e3850fe90c14d9acd6ceced45271180c990c8d6d597cf591d998eae48edf3dc9e11
7
+ data.tar.gz: aeb7cec816ce9de90ab3dcc72629d36c6f9b1ff69bfa618416e72fa5cbb6301cae5a5d12dd09d6d0a4c247c76095c397777dad9f42a18bbf5aaafa7d375b7248
data/CHANGELOG.md CHANGED
@@ -19,6 +19,55 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.34.2] - 2026-10-15
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Changed
27
+
28
+ - **Dependencies:** The gem now requires `mcp` below 2.0 (`>= 0.3, < 2`). The upper bound keeps
29
+ a future major of `mcp` from being picked up by `bundle update` and breaking the MCP server; the
30
+ test suite passes on both ends of the range, `mcp` 0.3.0 and 1.7.0. An application already on
31
+ `mcp` 2.x would have to stay on an earlier version of the gem until it supports it.
32
+
33
+ ### Fixed
34
+
35
+ - **MCP:** `explain_query` called against an unknown, offline or refused slave profiler now answers the dedicated message (`Error: Unknown slave profiler: ...`) instead of the generic `EXPLAIN failed`.
36
+ - **Assets:** The toolbar assets endpoints now answer a 404 that names the command to run (`yarn build:full` in the gem) when `app/assets/builds/` is empty or incomplete, instead of a 500.
37
+
38
+ ### Security
39
+
40
+ - **Build:** The client the dashboard is built from is now generated with `orval` 8
41
+ (`^8.41.0`, locked at 8.41.0) instead of 7.21.0, which carried eleven critical advisories of
42
+ code execution while generating from an OpenAPI document. `orval` only runs when the assets are
43
+ built, never in an application using the gem, and the generated client calls the same URLs with
44
+ the same methods and cache keys as before. Building the assets now needs Node.js 22.18 or later.
45
+ - **CI:** A `yarn:audit` job reports the advisories at level high and above on the JavaScript
46
+ build dependencies. It is informational: a finding shows as a warning and does not hold back a
47
+ publication.
48
+
49
+ ## [0.34.1] - 2026-10-15
50
+
51
+ <!-- stamped -->
52
+
53
+ ### Security
54
+
55
+ - **Explain:** The Explain button, `POST /_profiler/api/explain` and the MCP `explain_query` tool
56
+ now only run in the `development` and `test` environments, even where an application sets
57
+ `enabled = true`. Elsewhere, `production` and `staging` included, they are refused before
58
+ anything reaches the database: a `403` from the endpoint, an error from the tool. PostgreSQL's
59
+ `EXPLAIN ANALYZE` runs the query it explains, so a costly `SELECT` used to run for real on the
60
+ production database. Set `config.allow_explain_outside_development = true` to allow it there
61
+ anyway (false by default). The description of `explain_query` given to MCP clients now says so;
62
+ it claimed the tool was limited to development and test, which nothing enforced.
63
+ - **API:** An unexpected error no longer sends its message to the client. `POST
64
+ /_profiler/api/explain` answers `500` with `Explain failed`, `POST /_profiler/api/ajax/link`
65
+ with `Could not link the Ajax request`, the slave proxy (`/_profiler/api/slaves/:name/...`)
66
+ `502` with `Proxy error`, and `explain_query` with `EXPLAIN failed`. The message, which could
67
+ hold the SQL of the query, an internal file path or the address of a slave, goes to the
68
+ application log instead. The profiler's own refusals (a query that is not read-only, an index
69
+ out of range, an unknown or offline slave) still give their reason.
70
+
22
71
  ## [0.34.0] - 2026-10-15
23
72
 
24
73
  <!-- stamped -->
@@ -4211,6 +4211,18 @@
4211
4211
  }
4212
4212
 
4213
4213
  // app/assets/typescript/profiler/generated/api.ts
4214
+ var withQueryKey = (query, queryKey) => {
4215
+ const result = { queryKey };
4216
+ for (const key of Object.keys(query)) {
4217
+ if (key === "queryKey") continue;
4218
+ Object.defineProperty(result, key, {
4219
+ enumerable: true,
4220
+ configurable: true,
4221
+ get: () => query[key]
4222
+ });
4223
+ }
4224
+ return result;
4225
+ };
4214
4226
  var getToolbar = (token, signal) => {
4215
4227
  return apiFetch(
4216
4228
  {
@@ -4229,13 +4241,12 @@
4229
4241
  const { query: queryOptions } = options ?? {};
4230
4242
  const queryKey = queryOptions?.queryKey ?? getGetToolbarQueryKey(token);
4231
4243
  const queryFn = ({ signal }) => getToolbar(token, signal);
4232
- return { queryKey, queryFn, enabled: !!token, ...queryOptions };
4244
+ return { queryKey, queryFn, enabled: token !== null && token !== void 0, ...queryOptions };
4233
4245
  };
4234
4246
  function useGetToolbar(token, options) {
4235
4247
  const queryOptions = getGetToolbarQueryOptions(token, options);
4236
4248
  const query = useQuery(queryOptions);
4237
- query.queryKey = queryOptions.queryKey;
4238
- return query;
4249
+ return withQueryKey(query, queryOptions.queryKey);
4239
4250
  }
4240
4251
 
4241
4252
  // app/assets/typescript/profiler/hooks/profileUpdatePoller.ts
@@ -3472,6 +3472,18 @@
3472
3472
  }
3473
3473
 
3474
3474
  // app/assets/typescript/profiler/generated/api.ts
3475
+ var withQueryKey = (query, queryKey) => {
3476
+ const result = { queryKey };
3477
+ for (const key of Object.keys(query)) {
3478
+ if (key === "queryKey") continue;
3479
+ Object.defineProperty(result, key, {
3480
+ enumerable: true,
3481
+ configurable: true,
3482
+ get: () => query[key]
3483
+ });
3484
+ }
3485
+ return result;
3486
+ };
3475
3487
  var listConsoles = (params, signal) => {
3476
3488
  return apiFetch(
3477
3489
  {
@@ -3498,19 +3510,20 @@
3498
3510
  function useListConsolesInfinite(params, options) {
3499
3511
  const queryOptions = getListConsolesInfiniteQueryOptions(params, options);
3500
3512
  const query = useInfiniteQuery(queryOptions);
3501
- query.queryKey = queryOptions.queryKey;
3502
- return query;
3513
+ return withQueryKey(query, queryOptions.queryKey);
3503
3514
  }
3504
- var deleteConsole = (id) => {
3515
+ var deleteConsole = (id, signal) => {
3505
3516
  return apiFetch(
3506
3517
  {
3507
3518
  url: `/_profiler/api/console/${id}`,
3508
- method: "DELETE"
3519
+ method: "DELETE",
3520
+ signal
3509
3521
  }
3510
3522
  );
3511
3523
  };
3524
+ var getDeleteConsoleMutationKey = () => ["deleteConsole"];
3512
3525
  var getDeleteConsoleMutationOptions = (options) => {
3513
- const mutationKey = ["deleteConsole"];
3526
+ const mutationKey = getDeleteConsoleMutationKey();
3514
3527
  const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } : { mutation: { mutationKey } };
3515
3528
  const mutationFn = (props) => {
3516
3529
  const { id } = props ?? {};
@@ -3519,19 +3532,20 @@
3519
3532
  return { mutationFn, ...mutationOptions };
3520
3533
  };
3521
3534
  var useDeleteConsole = (options) => {
3522
- const mutationOptions = getDeleteConsoleMutationOptions(options);
3523
- return useMutation(mutationOptions);
3535
+ return useMutation(getDeleteConsoleMutationOptions(options));
3524
3536
  };
3525
- var clearConsoles = () => {
3537
+ var clearConsoles = (signal) => {
3526
3538
  return apiFetch(
3527
3539
  {
3528
3540
  url: `/_profiler/api/console/clear`,
3529
- method: "DELETE"
3541
+ method: "DELETE",
3542
+ signal
3530
3543
  }
3531
3544
  );
3532
3545
  };
3546
+ var getClearConsolesMutationKey = () => ["clearConsoles"];
3533
3547
  var getClearConsolesMutationOptions = (options) => {
3534
- const mutationKey = ["clearConsoles"];
3548
+ const mutationKey = getClearConsolesMutationKey();
3535
3549
  const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } : { mutation: { mutationKey } };
3536
3550
  const mutationFn = () => {
3537
3551
  return clearConsoles();
@@ -3539,8 +3553,7 @@
3539
3553
  return { mutationFn, ...mutationOptions };
3540
3554
  };
3541
3555
  var useClearConsoles = (options) => {
3542
- const mutationOptions = getClearConsolesMutationOptions(options);
3543
- return useMutation(mutationOptions);
3556
+ return useMutation(getClearConsolesMutationOptions(options));
3544
3557
  };
3545
3558
  var getEnvVars = (signal) => {
3546
3559
  return apiFetch(
@@ -3565,21 +3578,22 @@
3565
3578
  function useGetEnvVars(options) {
3566
3579
  const queryOptions = getGetEnvVarsQueryOptions(options);
3567
3580
  const query = useQuery(queryOptions);
3568
- query.queryKey = queryOptions.queryKey;
3569
- return query;
3581
+ return withQueryKey(query, queryOptions.queryKey);
3570
3582
  }
3571
- var updateEnvVar = (updateEnvVarBody) => {
3583
+ var updateEnvVar = (updateEnvVarBody, signal) => {
3572
3584
  return apiFetch(
3573
3585
  {
3574
3586
  url: `/_profiler/api/env_vars`,
3575
3587
  method: "PATCH",
3576
3588
  headers: { "Content-Type": "application/json" },
3577
- data: updateEnvVarBody
3589
+ data: updateEnvVarBody,
3590
+ signal
3578
3591
  }
3579
3592
  );
3580
3593
  };
3594
+ var getUpdateEnvVarMutationKey = () => ["updateEnvVar"];
3581
3595
  var getUpdateEnvVarMutationOptions = (options) => {
3582
- const mutationKey = ["updateEnvVar"];
3596
+ const mutationKey = getUpdateEnvVarMutationKey();
3583
3597
  const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } : { mutation: { mutationKey } };
3584
3598
  const mutationFn = (props) => {
3585
3599
  const { data } = props ?? {};
@@ -3588,20 +3602,21 @@
3588
3602
  return { mutationFn, ...mutationOptions };
3589
3603
  };
3590
3604
  var useUpdateEnvVar = (options) => {
3591
- const mutationOptions = getUpdateEnvVarMutationOptions(options);
3592
- return useMutation(mutationOptions);
3605
+ return useMutation(getUpdateEnvVarMutationOptions(options));
3593
3606
  };
3594
- var resetEnvVar = (params) => {
3607
+ var resetEnvVar = (params, signal) => {
3595
3608
  return apiFetch(
3596
3609
  {
3597
3610
  url: `/_profiler/api/env_vars/reset`,
3598
3611
  method: "DELETE",
3599
- params
3612
+ params,
3613
+ signal
3600
3614
  }
3601
3615
  );
3602
3616
  };
3617
+ var getResetEnvVarMutationKey = () => ["resetEnvVar"];
3603
3618
  var getResetEnvVarMutationOptions = (options) => {
3604
- const mutationKey = ["resetEnvVar"];
3619
+ const mutationKey = getResetEnvVarMutationKey();
3605
3620
  const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } : { mutation: { mutationKey } };
3606
3621
  const mutationFn = (props) => {
3607
3622
  const { params } = props ?? {};
@@ -3610,19 +3625,20 @@
3610
3625
  return { mutationFn, ...mutationOptions };
3611
3626
  };
3612
3627
  var useResetEnvVar = (options) => {
3613
- const mutationOptions = getResetEnvVarMutationOptions(options);
3614
- return useMutation(mutationOptions);
3628
+ return useMutation(getResetEnvVarMutationOptions(options));
3615
3629
  };
3616
- var resetAllEnvVars = () => {
3630
+ var resetAllEnvVars = (signal) => {
3617
3631
  return apiFetch(
3618
3632
  {
3619
3633
  url: `/_profiler/api/env_vars/reset_all`,
3620
- method: "DELETE"
3634
+ method: "DELETE",
3635
+ signal
3621
3636
  }
3622
3637
  );
3623
3638
  };
3639
+ var getResetAllEnvVarsMutationKey = () => ["resetAllEnvVars"];
3624
3640
  var getResetAllEnvVarsMutationOptions = (options) => {
3625
- const mutationKey = ["resetAllEnvVars"];
3641
+ const mutationKey = getResetAllEnvVarsMutationKey();
3626
3642
  const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } : { mutation: { mutationKey } };
3627
3643
  const mutationFn = () => {
3628
3644
  return resetAllEnvVars();
@@ -3630,8 +3646,7 @@
3630
3646
  return { mutationFn, ...mutationOptions };
3631
3647
  };
3632
3648
  var useResetAllEnvVars = (options) => {
3633
- const mutationOptions = getResetAllEnvVarsMutationOptions(options);
3634
- return useMutation(mutationOptions);
3649
+ return useMutation(getResetAllEnvVarsMutationOptions(options));
3635
3650
  };
3636
3651
  var explainQuery = (explainQueryBody, signal) => {
3637
3652
  return apiFetch(
@@ -3644,8 +3659,9 @@
3644
3659
  }
3645
3660
  );
3646
3661
  };
3662
+ var getExplainQueryMutationKey = () => ["explainQuery"];
3647
3663
  var getExplainQueryMutationOptions = (options) => {
3648
- const mutationKey = ["explainQuery"];
3664
+ const mutationKey = getExplainQueryMutationKey();
3649
3665
  const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } : { mutation: { mutationKey } };
3650
3666
  const mutationFn = (props) => {
3651
3667
  const { data } = props ?? {};
@@ -3654,21 +3670,22 @@
3654
3670
  return { mutationFn, ...mutationOptions };
3655
3671
  };
3656
3672
  var useExplainQuery = (options) => {
3657
- const mutationOptions = getExplainQueryMutationOptions(options);
3658
- return useMutation(mutationOptions);
3673
+ return useMutation(getExplainQueryMutationOptions(options));
3659
3674
  };
3660
- var updateFunctionProfiling = (updateFunctionProfilingBody) => {
3675
+ var updateFunctionProfiling = (updateFunctionProfilingBody, signal) => {
3661
3676
  return apiFetch(
3662
3677
  {
3663
3678
  url: `/_profiler/api/function_profiling`,
3664
3679
  method: "PATCH",
3665
3680
  headers: { "Content-Type": "application/json" },
3666
- data: updateFunctionProfilingBody
3681
+ data: updateFunctionProfilingBody,
3682
+ signal
3667
3683
  }
3668
3684
  );
3669
3685
  };
3686
+ var getUpdateFunctionProfilingMutationKey = () => ["updateFunctionProfiling"];
3670
3687
  var getUpdateFunctionProfilingMutationOptions = (options) => {
3671
- const mutationKey = ["updateFunctionProfiling"];
3688
+ const mutationKey = getUpdateFunctionProfilingMutationKey();
3672
3689
  const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } : { mutation: { mutationKey } };
3673
3690
  const mutationFn = (props) => {
3674
3691
  const { data } = props ?? {};
@@ -3677,8 +3694,7 @@
3677
3694
  return { mutationFn, ...mutationOptions };
3678
3695
  };
3679
3696
  var useUpdateFunctionProfiling = (options) => {
3680
- const mutationOptions = getUpdateFunctionProfilingMutationOptions(options);
3681
- return useMutation(mutationOptions);
3697
+ return useMutation(getUpdateFunctionProfilingMutationOptions(options));
3682
3698
  };
3683
3699
  var listJobs = (params, signal) => {
3684
3700
  return apiFetch(
@@ -3706,19 +3722,20 @@
3706
3722
  function useListJobsInfinite(params, options) {
3707
3723
  const queryOptions = getListJobsInfiniteQueryOptions(params, options);
3708
3724
  const query = useInfiniteQuery(queryOptions);
3709
- query.queryKey = queryOptions.queryKey;
3710
- return query;
3725
+ return withQueryKey(query, queryOptions.queryKey);
3711
3726
  }
3712
- var deleteJob = (id) => {
3727
+ var deleteJob = (id, signal) => {
3713
3728
  return apiFetch(
3714
3729
  {
3715
3730
  url: `/_profiler/api/jobs/${id}`,
3716
- method: "DELETE"
3731
+ method: "DELETE",
3732
+ signal
3717
3733
  }
3718
3734
  );
3719
3735
  };
3736
+ var getDeleteJobMutationKey = () => ["deleteJob"];
3720
3737
  var getDeleteJobMutationOptions = (options) => {
3721
- const mutationKey = ["deleteJob"];
3738
+ const mutationKey = getDeleteJobMutationKey();
3722
3739
  const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } : { mutation: { mutationKey } };
3723
3740
  const mutationFn = (props) => {
3724
3741
  const { id } = props ?? {};
@@ -3727,19 +3744,20 @@
3727
3744
  return { mutationFn, ...mutationOptions };
3728
3745
  };
3729
3746
  var useDeleteJob = (options) => {
3730
- const mutationOptions = getDeleteJobMutationOptions(options);
3731
- return useMutation(mutationOptions);
3747
+ return useMutation(getDeleteJobMutationOptions(options));
3732
3748
  };
3733
- var clearJobs = () => {
3749
+ var clearJobs = (signal) => {
3734
3750
  return apiFetch(
3735
3751
  {
3736
3752
  url: `/_profiler/api/jobs/clear`,
3737
- method: "DELETE"
3753
+ method: "DELETE",
3754
+ signal
3738
3755
  }
3739
3756
  );
3740
3757
  };
3758
+ var getClearJobsMutationKey = () => ["clearJobs"];
3741
3759
  var getClearJobsMutationOptions = (options) => {
3742
- const mutationKey = ["clearJobs"];
3760
+ const mutationKey = getClearJobsMutationKey();
3743
3761
  const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } : { mutation: { mutationKey } };
3744
3762
  const mutationFn = () => {
3745
3763
  return clearJobs();
@@ -3747,8 +3765,7 @@
3747
3765
  return { mutationFn, ...mutationOptions };
3748
3766
  };
3749
3767
  var useClearJobs = (options) => {
3750
- const mutationOptions = getClearJobsMutationOptions(options);
3751
- return useMutation(mutationOptions);
3768
+ return useMutation(getClearJobsMutationOptions(options));
3752
3769
  };
3753
3770
  var listOutboundRequests = (signal) => {
3754
3771
  return apiFetch(
@@ -3773,8 +3790,7 @@
3773
3790
  function useListOutboundRequests(options) {
3774
3791
  const queryOptions = getListOutboundRequestsQueryOptions(options);
3775
3792
  const query = useQuery(queryOptions);
3776
- query.queryKey = queryOptions.queryKey;
3777
- return query;
3793
+ return withQueryKey(query, queryOptions.queryKey);
3778
3794
  }
3779
3795
  var listProfiles = (params, signal) => {
3780
3796
  return apiFetch(
@@ -3802,19 +3818,20 @@
3802
3818
  function useListProfilesInfinite(params, options) {
3803
3819
  const queryOptions = getListProfilesInfiniteQueryOptions(params, options);
3804
3820
  const query = useInfiniteQuery(queryOptions);
3805
- query.queryKey = queryOptions.queryKey;
3806
- return query;
3821
+ return withQueryKey(query, queryOptions.queryKey);
3807
3822
  }
3808
- var deleteProfile = (id) => {
3823
+ var deleteProfile = (id, signal) => {
3809
3824
  return apiFetch(
3810
3825
  {
3811
3826
  url: `/_profiler/api/profiles/${id}`,
3812
- method: "DELETE"
3827
+ method: "DELETE",
3828
+ signal
3813
3829
  }
3814
3830
  );
3815
3831
  };
3832
+ var getDeleteProfileMutationKey = () => ["deleteProfile"];
3816
3833
  var getDeleteProfileMutationOptions = (options) => {
3817
- const mutationKey = ["deleteProfile"];
3834
+ const mutationKey = getDeleteProfileMutationKey();
3818
3835
  const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } : { mutation: { mutationKey } };
3819
3836
  const mutationFn = (props) => {
3820
3837
  const { id } = props ?? {};
@@ -3823,19 +3840,20 @@
3823
3840
  return { mutationFn, ...mutationOptions };
3824
3841
  };
3825
3842
  var useDeleteProfile = (options) => {
3826
- const mutationOptions = getDeleteProfileMutationOptions(options);
3827
- return useMutation(mutationOptions);
3843
+ return useMutation(getDeleteProfileMutationOptions(options));
3828
3844
  };
3829
- var clearProfiles = () => {
3845
+ var clearProfiles = (signal) => {
3830
3846
  return apiFetch(
3831
3847
  {
3832
3848
  url: `/_profiler/api/profiles/clear`,
3833
- method: "DELETE"
3849
+ method: "DELETE",
3850
+ signal
3834
3851
  }
3835
3852
  );
3836
3853
  };
3854
+ var getClearProfilesMutationKey = () => ["clearProfiles"];
3837
3855
  var getClearProfilesMutationOptions = (options) => {
3838
- const mutationKey = ["clearProfiles"];
3856
+ const mutationKey = getClearProfilesMutationKey();
3839
3857
  const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } : { mutation: { mutationKey } };
3840
3858
  const mutationFn = () => {
3841
3859
  return clearProfiles();
@@ -3843,8 +3861,7 @@
3843
3861
  return { mutationFn, ...mutationOptions };
3844
3862
  };
3845
3863
  var useClearProfiles = (options) => {
3846
- const mutationOptions = getClearProfilesMutationOptions(options);
3847
- return useMutation(mutationOptions);
3864
+ return useMutation(getClearProfilesMutationOptions(options));
3848
3865
  };
3849
3866
  var getTestRunnerFiles = (params, signal) => {
3850
3867
  return apiFetch(
@@ -3871,8 +3888,7 @@
3871
3888
  function useGetTestRunnerFiles(params, options) {
3872
3889
  const queryOptions = getGetTestRunnerFilesQueryOptions(params, options);
3873
3890
  const query = useQuery(queryOptions);
3874
- query.queryKey = queryOptions.queryKey;
3875
- return query;
3891
+ return withQueryKey(query, queryOptions.queryKey);
3876
3892
  }
3877
3893
  var createTestRun = (createTestRunBody, signal) => {
3878
3894
  return apiFetch(
@@ -3885,8 +3901,9 @@
3885
3901
  }
3886
3902
  );
3887
3903
  };
3904
+ var getCreateTestRunMutationKey = () => ["createTestRun"];
3888
3905
  var getCreateTestRunMutationOptions = (options) => {
3889
- const mutationKey = ["createTestRun"];
3906
+ const mutationKey = getCreateTestRunMutationKey();
3890
3907
  const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } : { mutation: { mutationKey } };
3891
3908
  const mutationFn = (props) => {
3892
3909
  const { data } = props ?? {};
@@ -3895,19 +3912,20 @@
3895
3912
  return { mutationFn, ...mutationOptions };
3896
3913
  };
3897
3914
  var useCreateTestRun = (options) => {
3898
- const mutationOptions = getCreateTestRunMutationOptions(options);
3899
- return useMutation(mutationOptions);
3915
+ return useMutation(getCreateTestRunMutationOptions(options));
3900
3916
  };
3901
- var deleteTestRun = (id) => {
3917
+ var deleteTestRun = (id, signal) => {
3902
3918
  return apiFetch(
3903
3919
  {
3904
3920
  url: `/_profiler/api/test_runner/runs/${id}`,
3905
- method: "DELETE"
3921
+ method: "DELETE",
3922
+ signal
3906
3923
  }
3907
3924
  );
3908
3925
  };
3926
+ var getDeleteTestRunMutationKey = () => ["deleteTestRun"];
3909
3927
  var getDeleteTestRunMutationOptions = (options) => {
3910
- const mutationKey = ["deleteTestRun"];
3928
+ const mutationKey = getDeleteTestRunMutationKey();
3911
3929
  const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } : { mutation: { mutationKey } };
3912
3930
  const mutationFn = (props) => {
3913
3931
  const { id } = props ?? {};
@@ -3916,8 +3934,7 @@
3916
3934
  return { mutationFn, ...mutationOptions };
3917
3935
  };
3918
3936
  var useDeleteTestRun = (options) => {
3919
- const mutationOptions = getDeleteTestRunMutationOptions(options);
3920
- return useMutation(mutationOptions);
3937
+ return useMutation(getDeleteTestRunMutationOptions(options));
3921
3938
  };
3922
3939
  var listTests = (params, signal) => {
3923
3940
  return apiFetch(
@@ -3945,19 +3962,20 @@
3945
3962
  function useListTestsInfinite(params, options) {
3946
3963
  const queryOptions = getListTestsInfiniteQueryOptions(params, options);
3947
3964
  const query = useInfiniteQuery(queryOptions);
3948
- query.queryKey = queryOptions.queryKey;
3949
- return query;
3965
+ return withQueryKey(query, queryOptions.queryKey);
3950
3966
  }
3951
- var deleteTest = (id) => {
3967
+ var deleteTest = (id, signal) => {
3952
3968
  return apiFetch(
3953
3969
  {
3954
3970
  url: `/_profiler/api/tests/${id}`,
3955
- method: "DELETE"
3971
+ method: "DELETE",
3972
+ signal
3956
3973
  }
3957
3974
  );
3958
3975
  };
3976
+ var getDeleteTestMutationKey = () => ["deleteTest"];
3959
3977
  var getDeleteTestMutationOptions = (options) => {
3960
- const mutationKey = ["deleteTest"];
3978
+ const mutationKey = getDeleteTestMutationKey();
3961
3979
  const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } : { mutation: { mutationKey } };
3962
3980
  const mutationFn = (props) => {
3963
3981
  const { id } = props ?? {};
@@ -3966,19 +3984,20 @@
3966
3984
  return { mutationFn, ...mutationOptions };
3967
3985
  };
3968
3986
  var useDeleteTest = (options) => {
3969
- const mutationOptions = getDeleteTestMutationOptions(options);
3970
- return useMutation(mutationOptions);
3987
+ return useMutation(getDeleteTestMutationOptions(options));
3971
3988
  };
3972
- var clearTests = () => {
3989
+ var clearTests = (signal) => {
3973
3990
  return apiFetch(
3974
3991
  {
3975
3992
  url: `/_profiler/api/tests/clear`,
3976
- method: "DELETE"
3993
+ method: "DELETE",
3994
+ signal
3977
3995
  }
3978
3996
  );
3979
3997
  };
3998
+ var getClearTestsMutationKey = () => ["clearTests"];
3980
3999
  var getClearTestsMutationOptions = (options) => {
3981
- const mutationKey = ["clearTests"];
4000
+ const mutationKey = getClearTestsMutationKey();
3982
4001
  const { mutation: mutationOptions } = options ? options.mutation && "mutationKey" in options.mutation && options.mutation.mutationKey ? options : { ...options, mutation: { ...options.mutation, mutationKey } } : { mutation: { mutationKey } };
3983
4002
  const mutationFn = () => {
3984
4003
  return clearTests();
@@ -3986,8 +4005,7 @@
3986
4005
  return { mutationFn, ...mutationOptions };
3987
4006
  };
3988
4007
  var useClearTests = (options) => {
3989
- const mutationOptions = getClearTestsMutationOptions(options);
3990
- return useMutation(mutationOptions);
4008
+ return useMutation(getClearTestsMutationOptions(options));
3991
4009
  };
3992
4010
 
3993
4011
  // app/assets/typescript/profiler/dashboard/utils.ts
@@ -32,8 +32,10 @@ module Profiler
32
32
  Profiler.storage.save(child_token, child_profile)
33
33
 
34
34
  render json: { success: true }
35
- rescue => e
36
- render json: { error: e.message }, status: :internal_server_error
35
+ rescue StandardError => e
36
+ # The storage's message can name an internal path: the log only.
37
+ Profiler.log_error("Ajax: could not link the request #{child_token.to_s.inspect} to #{parent_token.inspect}", e)
38
+ render json: { error: "Could not link the Ajax request" }, status: :internal_server_error
37
39
  end
38
40
  end
39
41
  end
@@ -6,8 +6,8 @@ module Profiler
6
6
  module Api
7
7
  class ExplainController < ApplicationController
8
8
  def create
9
- unless Profiler.configuration.enabled
10
- return render json: { error: "EXPLAIN is only available when the profiler is enabled" }, status: :forbidden
9
+ if (reason = Profiler::ExplainRunner.refusal)
10
+ return render json: { error: reason }, status: :forbidden
11
11
  end
12
12
 
13
13
  token = params[:token].to_s
@@ -19,10 +19,12 @@ module Profiler
19
19
 
20
20
  result = Profiler::ExplainRunner.run(token, query_index)
21
21
  render json: result
22
- rescue ArgumentError => e
22
+ rescue Profiler::ExplainRunner::RefusedError => e
23
23
  render json: { error: e.message }, status: :unprocessable_entity
24
- rescue => e
25
- render json: { error: e.message }, status: :internal_server_error
24
+ rescue StandardError => e
25
+ # The database's message carries the SQL, and sometimes a path: the log only.
26
+ Profiler.log_error("Explain: failed for query #{query_index} of profile #{token.inspect}", e)
27
+ render json: { error: "Explain failed" }, status: :internal_server_error
26
28
  end
27
29
  end
28
30
  end
@@ -26,10 +26,13 @@ module Profiler
26
26
  end
27
27
 
28
28
  render json: result
29
+ # The profiler's own refusals (unknown or offline slave, refused URL, missing secret).
29
30
  rescue Profiler::Error => e
30
31
  render json: { error: e.message }, status: :bad_gateway
31
- rescue => e
32
- render json: { error: "Proxy error: #{e.message}" }, status: :bad_gateway
32
+ rescue StandardError => e
33
+ # A network error names the slave's address, and sometimes a path: the log only.
34
+ Profiler.log_error("SlaveProxy: could not forward #{request.method} to slave profiler #{params[:slave_name].to_s.inspect}", e)
35
+ render json: { error: "Proxy error" }, status: :bad_gateway
33
36
  end
34
37
 
35
38
  private
@@ -8,25 +8,28 @@ module Profiler
8
8
  skip_before_action :check_authorization
9
9
  skip_before_action :authorize_request
10
10
 
11
+ BUILD_MISSING = "Profiler assets are not built (%s is missing). Run `yarn build:full` in the profiler gem."
12
+
11
13
  def toolbar_js
12
- path = Profiler::Engine.root.join("app", "assets", "builds", "profiler-toolbar.js")
13
- js = File.read(path)
14
- expires_in 1.hour, public: true
15
- render plain: js, content_type: "application/javascript"
14
+ serve("profiler-toolbar.js", "application/javascript")
16
15
  end
17
16
 
18
17
  def main_js
19
- path = Profiler::Engine.root.join("app", "assets", "builds", "profiler.js")
20
- js = File.read(path)
21
- expires_in 1.hour, public: true
22
- render plain: js, content_type: "application/javascript"
18
+ serve("profiler.js", "application/javascript")
23
19
  end
24
20
 
25
21
  def main_css
26
- path = Profiler::Engine.root.join("app", "assets", "builds", "profiler.css")
27
- css = File.read(path)
22
+ serve("profiler.css", "text/css")
23
+ end
24
+
25
+ private
26
+
27
+ def serve(name, content_type)
28
+ body = File.read(Profiler::Engine.root.join("app", "assets", "builds", name))
28
29
  expires_in 1.hour, public: true
29
- render plain: css, content_type: "text/css"
30
+ render plain: body, content_type: content_type
31
+ rescue Errno::ENOENT
32
+ render plain: format(BUILD_MISSING, name), status: :not_found
30
33
  end
31
34
  end
32
35
  end
@@ -48,7 +48,7 @@ module Profiler
48
48
  :track_jobs,
49
49
  :track_console,
50
50
  :apply_env_overrides_when_disabled, :restrict_storage_permissions,
51
- :test_runner_allow_undiscovered_files,
51
+ :test_runner_allow_undiscovered_files, :allow_explain_outside_development,
52
52
  :track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
53
53
  :compress_bodies, :compress_body_threshold, :max_captured_body_bytes, :max_captured_log_bytes,
54
54
  :redact_sensitive_data, :filter_parameters, :env_allowlist,
@@ -108,6 +108,10 @@ module Profiler
108
108
  # The test runner only runs the files listed by its discovery. true restores the
109
109
  # previous behavior: any file under the Rails root.
110
110
  @test_runner_allow_undiscovered_files = false
111
+ # Explain (the Database tab button, POST /_profiler/api/explain, the explain_query MCP tool)
112
+ # only runs in the development and test environments, even with enabled = true elsewhere:
113
+ # PostgreSQL's EXPLAIN ANALYZE runs the query. true allows it in every environment.
114
+ @allow_explain_outside_development = false
111
115
  @track_mailers = true
112
116
  @capture_mail_body = false
113
117
  @sanitize_mailer_recipients = false
@@ -11,9 +11,39 @@ module Profiler
11
11
  # what no reading of the statement can rule out (a function with a side effect),
12
12
  # on a connection of its own that is closed afterwards, in a transaction that is
13
13
  # always rolled back, read-only and time-limited on PostgreSQL.
14
+ #
15
+ # It only runs in the development and test environments, whatever config.enabled says,
16
+ # unless config.allow_explain_outside_development is set: PostgreSQL's EXPLAIN ANALYZE runs
17
+ # the query, and a costly SELECT would run for real on the production database.
14
18
  module ExplainRunner
15
- # An ArgumentError, so callers answer it as a bad request (422, MCP error).
16
- class UnsafeStatementError < ArgumentError; end
19
+ # A request the runner refuses, with a message written for the caller: an ArgumentError,
20
+ # so callers answer it as a bad request (422, MCP error). Any other error is internal, and
21
+ # its message (SQL, paths) stays in the server log.
22
+ class RefusedError < ArgumentError; end
23
+
24
+ class UnsafeStatementError < RefusedError; end
25
+
26
+ # The environments EXPLAIN runs in without config.allow_explain_outside_development.
27
+ ENVIRONMENTS = %w[development test].freeze
28
+
29
+ NOT_ENABLED = "EXPLAIN is only available when the profiler is enabled"
30
+ OUTSIDE_DEVELOPMENT = "EXPLAIN is only available in the development and test environments " \
31
+ "(config.allow_explain_outside_development allows it in the others)"
32
+
33
+ # Why EXPLAIN may not run in this process, or nil when it may. Without Rails, the
34
+ # environment is unknown and EXPLAIN is refused.
35
+ def self.refusal
36
+ config = Profiler.configuration
37
+ return NOT_ENABLED unless config.enabled
38
+ return nil if config.allow_explain_outside_development == true
39
+ return nil if ENVIRONMENTS.include?(current_environment)
40
+
41
+ OUTSIDE_DEVELOPMENT
42
+ end
43
+
44
+ def self.current_environment
45
+ Rails.env.to_s if defined?(Rails) && Rails.respond_to?(:env) && Rails.env
46
+ end
17
47
 
18
48
  # PostgreSQL's EXPLAIN ANALYZE runs the query: a slow one is stopped after this.
19
49
  PROBE_STATEMENT_TIMEOUT = "30s"
@@ -21,29 +51,29 @@ module Profiler
21
51
  # @param profile_token [String]
22
52
  # @param query_index [Integer]
23
53
  # @return [Hash] { result:, format: "json"|"text", adapter: String }
24
- # or raises ArgumentError / RuntimeError
54
+ # or raises SecurityError (see .refusal), RefusedError, or any error of the database
25
55
  def self.run(profile_token, query_index)
26
- unless Profiler.configuration.enabled
27
- raise SecurityError, "EXPLAIN is only available when the profiler is enabled"
56
+ if (reason = refusal)
57
+ raise SecurityError, reason
28
58
  end
29
59
 
30
60
  profile = Profiler.storage.load(profile_token)
31
- raise ArgumentError, "Profile not found: #{profile_token}" unless profile
61
+ raise RefusedError, "Profile not found: #{profile_token}" unless profile
32
62
 
33
63
  db_data = profile.collector_data("database")
34
- raise ArgumentError, "No database data in this profile" unless db_data && db_data["queries"]
64
+ raise RefusedError, "No database data in this profile" unless db_data && db_data["queries"]
35
65
 
36
66
  queries = db_data["queries"]
37
67
  query_index = query_index.to_i
38
68
  unless query_index >= 0 && query_index < queries.size
39
- raise ArgumentError, "Query index #{query_index} out of range (0..#{queries.size - 1})"
69
+ raise RefusedError, "Query index #{query_index} out of range (0..#{queries.size - 1})"
40
70
  end
41
71
 
42
72
  query = queries[query_index]
43
73
  sql = query["sql"].to_s
44
74
  binds = Array(query["binds"])
45
75
  if binds.include?(Redaction::MASK)
46
- raise ArgumentError, "EXPLAIN refused for query #{query_index}: one of its bind values was " \
76
+ raise RefusedError, "EXPLAIN refused for query #{query_index}: one of its bind values was " \
47
77
  "filtered when captured (config.filter_parameters), so the query cannot be rebuilt"
48
78
  end
49
79
 
@@ -176,7 +176,7 @@ module Profiler
176
176
  ),
177
177
  define_tool(
178
178
  name: "explain_query",
179
- description: "Run EXPLAIN ANALYZE on a specific query from a profile. Returns the query execution plan with cost and row estimates. Only read-only statements (SELECT, WITH ... SELECT, TABLE, VALUES) are explained, in a transaction always rolled back; other queries return an error. Only available in development/test environments.",
179
+ description: "Run EXPLAIN ANALYZE on a specific query from a profile. Returns the query execution plan with cost and row estimates. Only read-only statements (SELECT, WITH ... SELECT, TABLE, VALUES) are explained, in a transaction always rolled back; other queries return an error. Refused with an error unless the application that runs it (this one, or the slave given) has the profiler enabled and runs in the development or test environment; config.allow_explain_outside_development = true lifts the environment check.",
180
180
  input_schema: {
181
181
  properties: {
182
182
  token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
@@ -26,8 +26,8 @@ module Profiler
26
26
  return [{ type: "text", text: text }]
27
27
  end
28
28
 
29
- unless Profiler.configuration.enabled
30
- return [{ type: "text", text: "Error: EXPLAIN is only available when the profiler is enabled" }]
29
+ if (reason = Profiler::ExplainRunner.refusal)
30
+ return [{ type: "text", text: "Error: #{reason}" }]
31
31
  end
32
32
 
33
33
  data = Profiler::ExplainRunner.run(token, query_index.to_i)
@@ -47,10 +47,15 @@ module Profiler
47
47
  end
48
48
 
49
49
  [{ type: "text", text: lines.join("\n") }]
50
- rescue ArgumentError => e
50
+ rescue Profiler::ExplainRunner::RefusedError => e
51
51
  [{ type: "text", text: "Error: #{e.message}" }]
52
- rescue => e
53
- [{ type: "text", text: "EXPLAIN failed: #{e.message}" }]
52
+ rescue Profiler::Error => e
53
+ # The profiler's own refusals (unknown, offline or refused slave) carry a message made for the caller.
54
+ [{ type: "text", text: "Error: #{e.message}" }]
55
+ rescue StandardError => e
56
+ # The database's message carries the SQL, and sometimes a path: the log only.
57
+ Profiler.log_error("MCP explain_query: failed for query #{query_index.to_i} of profile #{token.to_s.inspect}", e)
58
+ [{ type: "text", text: "EXPLAIN failed (the application log has the details)" }]
54
59
  end
55
60
  end
56
61
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.34.0"
4
+ VERSION = "0.34.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.34.0
4
+ version: 0.34.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
@@ -59,6 +59,9 @@ dependencies:
59
59
  - - ">="
60
60
  - !ruby/object:Gem::Version
61
61
  version: '0.3'
62
+ - - "<"
63
+ - !ruby/object:Gem::Version
64
+ version: '2'
62
65
  type: :runtime
63
66
  prerelease: false
64
67
  version_requirements: !ruby/object:Gem::Requirement
@@ -66,6 +69,9 @@ dependencies:
66
69
  - - ">="
67
70
  - !ruby/object:Gem::Version
68
71
  version: '0.3'
72
+ - - "<"
73
+ - !ruby/object:Gem::Version
74
+ version: '2'
69
75
  - !ruby/object:Gem::Dependency
70
76
  name: webmock
71
77
  requirement: !ruby/object:Gem::Requirement