Skip to content

Commit 44b475c

Browse files
feat: add telemetry-core package for privacy-focused plugin analytics
- Create @voitanos/heft-plugins-telemetry-core package with: - TelemetryClient using Azure Application Insights - Privacy-safe context collection (OS, Node version, CI detection) - Message sanitization to remove PII (paths, emails, IPs, URLs) - Multiple opt-out mechanisms (HEFT_TELEMETRY_DISABLED, DO_NOT_TRACK, etc.) - Connection string injection script for CI/CD builds - Integrate telemetry into heft-stylelint-plugin: - Track plugin started, completed, errors, and warnings - Record execution duration and outcome metrics - Flush telemetry with timeout to avoid blocking builds - Add PRIVACY.md documenting data collection practices - feat(heft-stylelint-plugin): add postinstall script for installation tracking Tracks when the plugin is installed via npm/pnpm/yarn. The script: - Sends a single telemetry event on installation - Respects all telemetry opt-out environment variables - Can be skipped in CI with SKIP_INSTALL_TELEMETRY=1 - Never fails installation even if telemetry errors occur - build(telemetry-core): 🏗️ npm scripts to inject connection string - add two scripts to create & inject variables on local builds - to build local, run: ```console npm run [`build` | `build:prod'] npm run set-azappinsights-connection-string npm run inject-azappinsights-connection-string ```` - chore(telemetry-core): add conventional commit scope for telemetry-core - feat(telemetry-core): 🍱 update env var for connection string - docs(telemetry-core): 📝 add privcy statement - ci(publish): 👷 update publish to be dynamic - previously only supported heft-*-plugins - now also supports telemetry-code package --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e8560ff commit 44b475c

21 files changed

Lines changed: 8046 additions & 37 deletions

File tree

.github/workflows/plugin-publish.yml

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ on:
44
push:
55
tags:
66
- 'heft-*-plugin@v*.*.*'
7+
- 'telemetry-core@v*.*.*'
78

89
permissions:
910
contents: write
@@ -67,15 +68,31 @@ jobs:
6768
- name: Extract package name, version, and determine dist-tag
6869
id: version
6970
run: |
70-
# Extract from tag format: heft-stylelint-plugin@v0.1.0
71+
# Extract from tag format: heft-stylelint-plugin@v0.1.0 or telemetry-core@v0.1.0
7172
FULL_TAG=${GITHUB_REF#refs/tags/}
7273
PACKAGE_SHORT_NAME=${FULL_TAG%@v*}
7374
VERSION=${FULL_TAG#*@v}
7475
7576
echo "package_short_name=$PACKAGE_SHORT_NAME" >> $GITHUB_OUTPUT
7677
echo "version=$VERSION" >> $GITHUB_OUTPUT
7778
78-
echo "Publishing package from directory: heft-plugins/${PACKAGE_SHORT_NAME}"
79+
# Map package short name to folder path
80+
# Plugins live in heft-plugins/, libraries live in packages/
81+
case $PACKAGE_SHORT_NAME in
82+
heft-*-plugin)
83+
PACKAGE_PATH="heft-plugins/${PACKAGE_SHORT_NAME}"
84+
;;
85+
telemetry-core)
86+
PACKAGE_PATH="packages/${PACKAGE_SHORT_NAME}"
87+
;;
88+
*)
89+
echo "Error: Unknown package '${PACKAGE_SHORT_NAME}'"
90+
exit 1
91+
;;
92+
esac
93+
94+
echo "package_path=$PACKAGE_PATH" >> $GITHUB_OUTPUT
95+
echo "Publishing package from directory: $PACKAGE_PATH"
7996
echo "Version: $VERSION"
8097
8198
# Determine NPM dist-tag based on version format
@@ -96,11 +113,16 @@ jobs:
96113
- name: Get package full name from package.json
97114
id: package
98115
run: |
99-
cd heft-plugins/${{ steps.version.outputs.package_short_name }}
116+
cd ${{ steps.version.outputs.package_path }}
100117
PACKAGE_NAME=$(node -p "require('./package.json').name")
101118
echo "name=$PACKAGE_NAME" >> $GITHUB_OUTPUT
102119
echo "Full package name: $PACKAGE_NAME"
103120
121+
- name: Inject AppInsights connection string into @voitanos/heft-plugins-telemetry-core
122+
run: node packages/telemetry-core/scripts/inject-connection-string.js
123+
env:
124+
HEFT_PLUGINS_APP_INSIGHTS_CONNECTION_STRING: ${{ vars.HEFT_PLUGINS_APP_INSIGHTS_CONNECTION_STRING }}
125+
104126
- name: Configure npm for OIDC authentication
105127
run: |
106128
npm config delete //registry.npmjs.org/:_authToken 2>/dev/null || true
@@ -109,7 +131,7 @@ jobs:
109131
110132
- name: Publish specific package to NPM with provenance
111133
run: |
112-
cd heft-plugins/${{ steps.version.outputs.package_short_name }}
134+
cd ${{ steps.version.outputs.package_path }}
113135
npm publish --access public --tag ${{ steps.version.outputs.dist_tag }} --provenance
114136
115137
- name: Create GitHub Release

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
"conventionalCommits.scopes": [
33
"publish",
44
"heft-stylelint-plugin",
5+
"telemetry-core"
56
]
67
}

common/config/rush/pnpm-lock.yaml

Lines changed: 457 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

common/config/rush/version-policies.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
[
2+
{
3+
"definitionName": "individualVersion",
4+
"policyName": "telemetry-core-policy",
5+
"lockedMajor": 0
6+
},
27
{
38
"definitionName": "individualVersion",
49
"policyName": "heft-stylelint-plugin-policy",

heft-plugins/heft-stylelint-plugin/config/jest.config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
{
22
"testMatch": ["<rootDir>/src/**/*.test.ts"],
33
"moduleNameMapper": {
4-
"^(\\.{1,2}/.*)\\.js$": "$1"
4+
"^(\\.{1,2}/.*)\\.js$": "$1",
5+
"^@voitanos/heft-plugins-telemetry-core$": "<rootDir>/../../packages/telemetry-core/src/index.ts"
56
},
67
"extensionsToTreatAsEsm": [".ts"],
78
"transform": {

heft-plugins/heft-stylelint-plugin/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"typings": "lib/index.d.ts",
1818
"files": [
1919
"lib/",
20+
"scripts/",
2021
"heft-plugin.json"
2122
],
2223
"exports": {
@@ -28,9 +29,11 @@
2829
"scripts": {
2930
"build": "heft build --clean",
3031
"test": "heft test --clean",
31-
"start": "heft build-watch"
32+
"start": "heft build-watch",
33+
"postinstall": "node scripts/postinstall.js"
3234
},
3335
"dependencies": {
36+
"@voitanos/heft-plugins-telemetry-core": "0.1.0",
3437
"stylelint": "16.25.0",
3538
"stylelint-config-standard-scss": "16.0.0"
3639
},
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Postinstall script to track plugin installations.
4+
*
5+
* This script runs automatically after the package is installed via npm/pnpm/yarn.
6+
* It sends a single telemetry event to track that the plugin was installed.
7+
*
8+
* Telemetry can be disabled by setting any of these environment variables:
9+
* - HEFT_TELEMETRY_DISABLED=1
10+
* - DISABLE_TELEMETRY=1
11+
* - DO_NOT_TRACK=1
12+
* - TELEMETRY_DISABLED=1
13+
*
14+
* In CI environments, you can also set SKIP_INSTALL_TELEMETRY=1 to skip this script.
15+
*/
16+
17+
import { TelemetryClient, isTelemetryDisabled } from '@voitanos/heft-plugins-telemetry-core';
18+
import { readFileSync } from 'node:fs';
19+
import { fileURLToPath } from 'node:url';
20+
import * as path from 'node:path';
21+
22+
// Get package info
23+
const __filename = fileURLToPath(import.meta.url);
24+
const __dirname = path.dirname(__filename);
25+
const packageJsonPath = path.resolve(__dirname, '../package.json');
26+
27+
async function trackInstallation() {
28+
// Skip if telemetry is disabled
29+
if (isTelemetryDisabled()) {
30+
return;
31+
}
32+
33+
// Skip if explicitly disabled for installs (useful in CI during development)
34+
if (process.env.SKIP_INSTALL_TELEMETRY === '1') {
35+
return;
36+
}
37+
38+
try {
39+
// Read package info
40+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
41+
const pluginName = packageJson.name;
42+
const pluginVersion = packageJson.version;
43+
44+
// Create telemetry client
45+
const telemetry = new TelemetryClient({
46+
pluginName,
47+
pluginVersion,
48+
});
49+
50+
// Track installation
51+
telemetry.trackPluginInstalled();
52+
53+
// Flush with a short timeout (don't delay install too long)
54+
await Promise.race([
55+
telemetry.flush(),
56+
new Promise(resolve => setTimeout(resolve, 3000)),
57+
]);
58+
} catch {
59+
// Silently ignore any errors - telemetry should never break installation
60+
}
61+
}
62+
63+
// Run the tracking
64+
trackInstallation().catch(() => {
65+
// Silently ignore - telemetry errors should never break installation
66+
});

heft-plugins/heft-stylelint-plugin/src/StylelintPlugin.ts

Lines changed: 90 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,55 +4,116 @@ import type {
44
HeftConfiguration,
55
IHeftTaskSession,
66
IHeftTaskPlugin,
7-
IHeftTaskRunHookOptions,
8-
IScopedLogger
97
} from '@rushstack/heft';
108
import stylelint from 'stylelint';
9+
import { TelemetryClient, PluginOutcome } from '@voitanos/heft-plugins-telemetry-core';
1110

1211
export const PLUGIN_NAME: 'stylelint-plugin' = 'stylelint-plugin';
12+
const PACKAGE_NAME = '@voitanos/heft-stylelint-plugin';
13+
14+
/**
15+
* Get the package version for telemetry.
16+
* Note: This reads from package.json at runtime to get the actual deployed version.
17+
*/
18+
function getPackageVersion(buildFolderPath: string): string {
19+
try {
20+
// When installed as a dependency, the package.json will be in the package root
21+
const packageJsonPath = path.resolve(buildFolderPath, 'node_modules', PACKAGE_NAME, 'package.json');
22+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
23+
return packageJson.version;
24+
} catch {
25+
// Fallback: return a default version if package.json can't be read
26+
return '0.0.0-unknown';
27+
}
28+
}
1329

1430
export interface IStylelintPluginOptions { }
1531

1632
export default class StylelintPlugin implements IHeftTaskPlugin<IStylelintPluginOptions> {
33+
private _telemetry: TelemetryClient | undefined;
1734

1835
public apply(taskSession: IHeftTaskSession, heftConfiguration: HeftConfiguration, options: IStylelintPluginOptions): void {
1936
taskSession.hooks.run.tapPromise({
2037
name: PLUGIN_NAME,
2138
stage: Number.MIN_SAFE_INTEGER
2239
}, async () => {
23-
// output version of stylelint
24-
const stylelintPkgPath = path.join(heftConfiguration.buildFolderPath, 'node_modules/stylelint/package.json');
25-
const stylelintPkg = JSON.parse(readFileSync(stylelintPkgPath, 'utf-8'));
26-
taskSession.logger.terminal.writeLine(`Using Stylelint version ${stylelintPkg.version}`)
27-
if (taskSession.parameters.verbose) {
28-
taskSession.logger.terminal.writeVerboseLine(`path ${heftConfiguration.buildFolderPath}`)
29-
}
40+
const startTime = Date.now();
41+
let warningCount = 0;
3042

31-
// run stylelint on SCSS & CSS files using Node.js API
32-
if (taskSession.parameters.verbose) {
33-
taskSession.logger.terminal.writeVerboseLine('linting...');
34-
}
35-
const result = await stylelint.lint({
36-
files: 'src/**/*.scss',
37-
configFile: path.join(heftConfiguration.buildFolderPath, '.stylelintrc'),
38-
cwd: heftConfiguration.buildFolderPath
39-
});
40-
41-
if (taskSession.parameters.verbose) {
42-
taskSession.logger.terminal.writeVerboseLine(`results: ${JSON.stringify(result)}`);
43+
// Initialize telemetry lazily (needs buildFolderPath for version detection)
44+
if (!this._telemetry) {
45+
this._telemetry = new TelemetryClient({
46+
pluginName: PACKAGE_NAME,
47+
pluginVersion: getPackageVersion(heftConfiguration.buildFolderPath),
48+
});
4349
}
4450

45-
// process results
46-
if (result.errored || result.results.some(r => r.warnings.length > 0)) {
47-
for (const fileResult of result.results) {
48-
if (fileResult.warnings.length > 0 && fileResult.source) {
49-
for (const warning of fileResult.warnings) {
50-
const relativePath = path.relative(heftConfiguration.buildFolderPath, fileResult.source);
51-
const formattedWarning = `${relativePath}:${warning.line}:${warning.column} - (${warning.rule}) ${warning.text}`;
52-
taskSession.logger.terminal.writeWarningLine(formattedWarning);
51+
// Track plugin start
52+
this._telemetry.trackPluginStarted();
53+
54+
try {
55+
// output version of stylelint
56+
const stylelintPkgPath = path.join(heftConfiguration.buildFolderPath, 'node_modules/stylelint/package.json');
57+
const stylelintPkg = JSON.parse(readFileSync(stylelintPkgPath, 'utf-8'));
58+
taskSession.logger.terminal.writeLine(`Using Stylelint version ${stylelintPkg.version}`)
59+
if (taskSession.parameters.verbose) {
60+
taskSession.logger.terminal.writeVerboseLine(`path ${heftConfiguration.buildFolderPath}`)
61+
}
62+
63+
// run stylelint on SCSS & CSS files using Node.js API
64+
if (taskSession.parameters.verbose) {
65+
taskSession.logger.terminal.writeVerboseLine('linting...');
66+
}
67+
const result = await stylelint.lint({
68+
files: 'src/**/*.scss',
69+
configFile: path.join(heftConfiguration.buildFolderPath, '.stylelintrc'),
70+
cwd: heftConfiguration.buildFolderPath
71+
});
72+
73+
if (taskSession.parameters.verbose) {
74+
taskSession.logger.terminal.writeVerboseLine(`results: ${JSON.stringify(result)}`);
75+
}
76+
77+
// process results
78+
if (result.errored || result.results.some(r => r.warnings.length > 0)) {
79+
for (const fileResult of result.results) {
80+
if (fileResult.warnings.length > 0 && fileResult.source) {
81+
for (const warning of fileResult.warnings) {
82+
warningCount++;
83+
const relativePath = path.relative(heftConfiguration.buildFolderPath, fileResult.source);
84+
const formattedWarning = `${relativePath}:${warning.line}:${warning.column} - (${warning.rule}) ${warning.text}`;
85+
taskSession.logger.terminal.writeWarningLine(formattedWarning);
86+
87+
// Track warning (category is the stylelint rule)
88+
this._telemetry.trackPluginWarning(
89+
warning.rule || 'unknown',
90+
`Stylelint warning: ${warning.text}`
91+
);
92+
}
5393
}
5494
}
5595
}
96+
97+
// Track successful completion
98+
const duration = Date.now() - startTime;
99+
const outcome = warningCount > 0 ? PluginOutcome.Warning : PluginOutcome.Success;
100+
this._telemetry.trackPluginCompleted(duration, outcome, warningCount);
101+
102+
} catch (error) {
103+
// Track error
104+
const duration = Date.now() - startTime;
105+
this._telemetry.trackPluginError(error, duration);
106+
this._telemetry.trackPluginCompleted(duration, PluginOutcome.Error, warningCount);
107+
throw error;
108+
109+
} finally {
110+
// Flush telemetry with timeout to avoid blocking
111+
await Promise.race([
112+
this._telemetry.flush(),
113+
new Promise(resolve => setTimeout(resolve, 2000))
114+
]).catch(() => {
115+
// Silently ignore flush errors
116+
});
56117
}
57118
});
58119
}

0 commit comments

Comments
 (0)