-
Notifications
You must be signed in to change notification settings - Fork 7
GitHub Issue #1130: Metrics to track R & Python package usage #7881
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
4de8ef9
d98a36b
76cbf5e
cea599a
4b91d83
f79850e
10e9b21
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,7 @@ | |
| import org.labkey.api.miniprofiler.MiniProfiler; | ||
| import org.labkey.api.pipeline.PipelineJobService; | ||
| import org.labkey.api.reader.Readers; | ||
| import org.labkey.api.reports.report.ScriptPackageUsageTracker; | ||
| import org.labkey.api.reports.report.r.ParamReplacementSvc; | ||
| import org.labkey.api.util.ExceptionUtil; | ||
| import org.labkey.api.util.LabKeyProcessBuilder; | ||
|
|
@@ -113,15 +114,127 @@ public boolean isBinary(FileLike file) | |
| public Object eval(String script, ScriptContext context) throws ScriptException | ||
| { | ||
| List<String> extensions = getFactory().getExtensions(); | ||
| if (extensions.isEmpty()) | ||
| throw new ScriptException("There are no file name extensions registered for this ScriptEngine : " + getFactory().getLanguageName()); | ||
|
|
||
| String epilog = getPackageCaptureEpilog(context); | ||
| if (epilog != null) | ||
| script = script + "\n" + epilog; | ||
|
|
||
| if (!extensions.isEmpty()) | ||
| FileLike scriptFile = prepareScriptFile(script, context, extensions); | ||
| try | ||
| { | ||
| // write out the script file to disk using the first extension as the default | ||
| FileLike scriptFile = writeScriptFile(script, context, extensions); | ||
| return eval(scriptFile, context); | ||
| Object result = eval(scriptFile, context); | ||
|
|
||
| // Metric tracking must never affect script execution, so swallow any failure here | ||
| try | ||
| { | ||
| recordSuccessfulRun(context); | ||
| } | ||
| catch (Exception e) | ||
| { | ||
| LOG.warn("Failed to record successful script run", e); | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
| finally | ||
| { | ||
| // Guard so a metric failure can't mask the script's result or a real exception | ||
| try | ||
| { | ||
| recordPackageUsage(context); | ||
| } | ||
| catch (Exception e) | ||
| { | ||
| LOG.warn("Failed to record script package usage", e); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Prepare the on-disk script file that will be executed. The default writes the script as-is; subclasses (e.g. the | ||
| * R engine's knitr handling) may wrap it in a different driver script. | ||
| */ | ||
| protected FileLike prepareScriptFile(String script, ScriptContext context, List<String> extensions) | ||
| { | ||
| return writeScriptFile(script, context, extensions); | ||
| } | ||
|
|
||
| /** | ||
| * GitHub Issue #1130 | ||
| * Script appended to the end of the user script (in the same process) that captures the loaded packages/modules and | ||
| * writes them, one per line, to a sidecar file in the working directory for {@link #recordPackageUsage} to read | ||
| * back. The default returns null (no capture); language-specific engines (e.g. R, Python) override this. Wrapped so | ||
| * a capture failure can never break the script run. | ||
| */ | ||
| protected @Nullable String getPackageCaptureEpilog(ScriptContext context) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * GitHub Issue #1130 | ||
| * After a script runs, read back and record the packages loaded by the script. The default does nothing; | ||
| * language-specific engines override this, typically delegating to {@link #readPackageSidecar}. Never throws: | ||
| * package tracking must not affect script execution. | ||
| */ | ||
| protected void recordPackageUsage(ScriptContext context) | ||
| { | ||
| } | ||
|
|
||
| /** | ||
| * GitHub Issue #1130 | ||
| * Called after a script has run successfully (eval returned without throwing). The default does nothing; | ||
| * language-specific engines may override to record success metrics. | ||
| */ | ||
| protected void recordSuccessfulRun(ScriptContext context) | ||
| { | ||
| } | ||
|
|
||
| /** | ||
| * GitHub Issue #1130 | ||
| * Read a sidecar file of package names (one per line) from the working directory and record each under the given | ||
| * language in {@link ScriptPackageUsageTracker}. Never throws. A missing file means the script errored before the | ||
| * capture epilog ran (or capture was skipped) - nothing to do. The file is deleted after reading. | ||
| */ | ||
| protected void readPackageSidecar(ScriptContext context, String fileName, String language) | ||
| { | ||
| FileLike packagesFile; | ||
| try | ||
| { | ||
| packagesFile = getWorkingDir(context).resolveChild(fileName); | ||
| } | ||
| catch (Exception e) | ||
| { | ||
| LOG.warn("Failed to locate " + language + " package usage sidecar", e); | ||
| return; | ||
| } | ||
|
|
||
| if (!packagesFile.exists()) | ||
| return; | ||
|
|
||
| try (BufferedReader reader = Readers.getReader(packagesFile.openInputStream())) | ||
| { | ||
| String packageName; | ||
| while ((packageName = reader.readLine()) != null) | ||
| ScriptPackageUsageTracker.record(language, packageName); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider truncating long package names so we don't hit the DB limit of
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For extra protection, could also limit the list we track to 100 or 250 packages. |
||
| } | ||
| catch (Exception e) | ||
| { | ||
| LOG.warn("Failed to record " + language + " package usage", e); | ||
| } | ||
| finally | ||
| { | ||
| try | ||
| { | ||
| packagesFile.delete(); | ||
| } | ||
| catch (Exception e) | ||
| { | ||
| LOG.warn("Failed to delete " + language + " package usage sidecar", e); | ||
| } | ||
| } | ||
| else | ||
| throw new ScriptException("There are no file name extensions registered for this ScriptEngine : " + getFactory().getLanguageName()); | ||
| } | ||
|
|
||
| protected Object eval(FileLike scriptFile, ScriptContext context) throws ScriptException | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| /* | ||
| * Copyright (c) 2026 LabKey Corporation | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
| package org.labkey.api.reports.report; | ||
|
|
||
| import org.labkey.api.reports.ExternalScriptEngine; | ||
| import org.labkey.api.usageMetrics.SimpleMetricsService; | ||
|
|
||
| import java.util.Map; | ||
| import java.util.Set; | ||
|
|
||
| /** | ||
| * Tracks which packages/modules are loaded by scripts run on this server (R reports, assay transform scripts, Python | ||
| * scripts, and anything else that runs through {@link ExternalScriptEngine}). Populated by a language-specific epilog | ||
| * appended to each script that captures the loaded packages and writes them to a sidecar file, which the engine reads | ||
| * back after the script runs. Usage is tracked per language (e.g. "r", "python"). | ||
| * | ||
| * Each load is recorded via {@link SimpleMetricsService}, which persists a cumulative per-package load count across | ||
| * restarts and reports it to mothership under "simpleMetricCounts". The package name is the metric name and the feature | ||
| * area is "<language>PackageUsage". | ||
| */ | ||
| public class ScriptPackageUsageTracker | ||
| { | ||
| private static final String MODULE_NAME = "API"; | ||
| private static final String FEATURE_AREA_SUFFIX = "PackageUsage"; | ||
|
|
||
| /** | ||
| * Packages that ship with a given language's runtime and are always present, so aren't interesting as "library | ||
| * usage". R's base packages are filtered here; Python's standard library is filtered in the capture epilog itself | ||
| * (via sys.stdlib_module_names), so no Python entry is needed. | ||
| */ | ||
| private static final Map<String, Set<String>> BASE_PACKAGES = Map.of( | ||
| "r", Set.of("base", "compiler", "datasets", "graphics", "grDevices", "methods", "stats", "utils") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude thinks that these are missing from the list: grid, tools, parallel, splines, stats4, and tcltk |
||
| ); | ||
|
|
||
| private ScriptPackageUsageTracker() | ||
| { | ||
| } | ||
|
|
||
| private static boolean isBasePackage(String language, String packageName) | ||
| { | ||
| return BASE_PACKAGES.getOrDefault(language, Set.of()).contains(packageName); | ||
| } | ||
|
|
||
| /** | ||
| * Record that the given package was loaded by a script run in the given language (e.g. "r", "python"). Safe to call | ||
| * repeatedly; base packages and blank names are ignored. | ||
| */ | ||
| public static void record(String language, String packageName) | ||
| { | ||
| if (packageName == null || packageName.isBlank() || isBasePackage(language, packageName)) | ||
| return; | ||
|
|
||
| SimpleMetricsService.get().increment(MODULE_NAME, language + FEATURE_AREA_SUFFIX, packageName); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| /* | ||
| * Copyright (c) 2026 LabKey Corporation | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
| package org.labkey.api.reports.report.python; | ||
|
|
||
| import org.jetbrains.annotations.Nullable; | ||
| import org.labkey.api.reports.ExternalScriptEngine; | ||
| import org.labkey.api.reports.ExternalScriptEngineDefinition; | ||
|
|
||
| import javax.script.ScriptContext; | ||
|
|
||
| /** | ||
| * Script engine for locally-executed Python scripts (Python assay transform scripts configured as an | ||
| * external ".py" engine). Behaves like the base {@link ExternalScriptEngine} except that it appends a capture epilog to | ||
| * track which Python modules each script loads; see | ||
| * {@link org.labkey.api.reports.report.ScriptPackageUsageTracker}. | ||
| */ | ||
| public class PythonScriptEngine extends ExternalScriptEngine | ||
| { | ||
| private static final String PACKAGES_FILE = "labkeyPythonPackages.txt"; | ||
|
|
||
| // Python appended to the end of a user script to capture the loaded modules (top-level names, excluding the standard | ||
| // library). Wrapped in try/except so a capture failure can never break the script run. | ||
| private static final String PACKAGE_CAPTURE_EPILOG = """ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This doesn't handle scripts that exit with success via a https://docs.python.org/3/library/site.html
|
||
| # --- LabKey Python package usage capture --- | ||
| try: | ||
| import sys as _lk_sys | ||
| _lk_stdlib = set(getattr(_lk_sys, 'stdlib_module_names', ())) | ||
| _lk_mods = sorted({m.split('.')[0] for m in list(_lk_sys.modules)} - _lk_stdlib) | ||
| with open('%s', 'w') as _lk_f: | ||
| _lk_f.write('\\n'.join(m for m in _lk_mods if m and not m.startswith('_'))) | ||
| except Exception: | ||
| pass | ||
| """.formatted(PACKAGES_FILE); | ||
|
|
||
| public PythonScriptEngine(ExternalScriptEngineDefinition def) | ||
| { | ||
| super(def); | ||
| } | ||
|
|
||
| @Override | ||
| protected @Nullable String getPackageCaptureEpilog(ScriptContext context) | ||
| { | ||
| return PACKAGE_CAPTURE_EPILOG; | ||
| } | ||
|
|
||
| @Override | ||
| protected void recordPackageUsage(ScriptContext context) | ||
| { | ||
| readPackageSidecar(context, PACKAGES_FILE, "python"); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't dive deep to fully prove it to myself, but Claude thinks that RserveScriptEngine won't capture the package lists.