snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
1
internal/apps-script/.gitignore
vendored
Normal file
1
internal/apps-script/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.clasp.json
|
||||
23
internal/apps-script/README.md
Normal file
23
internal/apps-script/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# tldraw Google Add-ons
|
||||
|
||||
This is an experimental/in-development library to create add-ons for Google Meet.
|
||||
|
||||
## Contribution
|
||||
|
||||
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
|
||||
|
||||
## License
|
||||
|
||||
The code in this folder is Copyright (c) 2024-present tldraw Inc. The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
|
||||
|
||||
## Trademarks
|
||||
|
||||
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
|
||||
|
||||
## Contact
|
||||
|
||||
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
|
||||
|
||||
## Community
|
||||
|
||||
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
|
||||
33
internal/apps-script/appsscript.json
Normal file
33
internal/apps-script/appsscript.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"addOns": {
|
||||
"common": {
|
||||
"layoutProperties": {
|
||||
"primaryColor": "#000",
|
||||
"secondaryColor": "#fff"
|
||||
},
|
||||
"logoUrl": "TLDRAW_HOST/android-chrome-maskable-512x512.png",
|
||||
"name": "tldraw"
|
||||
},
|
||||
"meet": {
|
||||
"web": {
|
||||
"sidePanelUri": "TLDRAW_HOST/ts-side",
|
||||
"mainStageUri": "TLDRAW_HOST/ts",
|
||||
"supportsScreenSharing": true,
|
||||
"logoUrl": "TLDRAW_HOST/android-chrome-maskable-512x512.png",
|
||||
"addOnOrigins": ["TLDRAW_HOST"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"timeZone": "Europe/London",
|
||||
"dependencies": {},
|
||||
"exceptionLogging": "STACKDRIVER",
|
||||
"runtimeVersion": "V8",
|
||||
"oauthScopes": [
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/documents.currentonly",
|
||||
"https://www.googleapis.com/auth/script.external_request",
|
||||
"https://www.googleapis.com/auth/workspace.linkpreview"
|
||||
],
|
||||
"urlFetchWhitelist": ["TLDRAW_HOST/"]
|
||||
}
|
||||
30
internal/apps-script/build-workspace-app.ts
Normal file
30
internal/apps-script/build-workspace-app.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { mkdirSync, writeFileSync } from 'fs'
|
||||
import path from 'path'
|
||||
import { exec } from '../../internal/scripts/lib/exec'
|
||||
import { readFileIfExists } from '../../internal/scripts/lib/file'
|
||||
|
||||
async function build() {
|
||||
await exec('rm', ['-rf', 'dist'])
|
||||
mkdirSync('dist')
|
||||
|
||||
const appsScriptPath = './appsscript.json'
|
||||
|
||||
await exec('cp', ['-r', appsScriptPath, 'dist'])
|
||||
|
||||
const isProduction = process.env.IS_PRODUCTION === '1'
|
||||
const scriptId = isProduction
|
||||
? '1FWcAvz7Rl4iPXQX3KmXm2mNG_RK2kryS7Bja8Y7RHvuAHnic51p_pqe7'
|
||||
: '1cJfZM0M_rGU-nYgG-4KR1DnERb7itkCsl1QmlqPxFvHnrz5n6Gfy8iht'
|
||||
writeFileSync('./.clasp.json', `{"scriptId":"${scriptId}","rootDir":"./dist"}`)
|
||||
|
||||
const host = isProduction ? 'https://www.tldraw.com' : 'https://staging.tldraw.com'
|
||||
await replaceInFile(appsScriptPath, 'TLDRAW_HOST', host)
|
||||
}
|
||||
|
||||
async function replaceInFile(filename: string, searchValue: string, replaceValue: string) {
|
||||
let contents = (await readFileIfExists(path.join('dist', filename))) ?? ''
|
||||
contents = contents.replaceAll(searchValue, replaceValue)
|
||||
writeFileSync(path.join('dist', filename), contents)
|
||||
}
|
||||
|
||||
build()
|
||||
47
internal/apps-script/package.json
Normal file
47
internal/apps-script/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "apps-scripts",
|
||||
"description": "tldraw for Google Apps",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"author": {
|
||||
"name": "tldraw Inc.",
|
||||
"email": "hello@tldraw.com"
|
||||
},
|
||||
"homepage": "https://tldraw.dev",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tldraw/tldraw"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/tldraw/tldraw/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"tldraw",
|
||||
"sdk",
|
||||
"drawing",
|
||||
"app",
|
||||
"development",
|
||||
"whiteboard",
|
||||
"canvas",
|
||||
"infinite"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "yarn run -T tsx ../scripts/lint.ts",
|
||||
"glogin": "clasp login",
|
||||
"glogout": "clasp logout",
|
||||
"gcreate": "clasp create --type standalone --title \"tldraw.com\" --rootDir ./dist",
|
||||
"gpull": "yarn build && clasp pull",
|
||||
"gpush": "yarn build && clasp push",
|
||||
"gpull:staging": "yarn build:staging && clasp pull",
|
||||
"gpush:staging": "yarn build:staging && clasp push",
|
||||
"build": "IS_PRODUCTION=1 yarn run -T tsx build-workspace-app.ts",
|
||||
"build:staging": "yarn run -T tsx build-workspace-app.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/clasp": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/google-apps-script": "^1.0.91"
|
||||
}
|
||||
}
|
||||
23
internal/apps-script/tsconfig.json
Normal file
23
internal/apps-script/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"target": "esnext",
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"types": ["node"],
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }]
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"],
|
||||
"references": []
|
||||
}
|
||||
1
internal/config/LICENSE.md
Normal file
1
internal/config/LICENSE.md
Normal file
@@ -0,0 +1 @@
|
||||
This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)
|
||||
406
internal/config/api-extractor.json
Normal file
406
internal/config/api-extractor.json
Normal file
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* Config file for API Extractor. For more info, please visit: https://api-extractor.com
|
||||
*/
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||
/**
|
||||
* Optionally specifies another JSON config file that this file extends from. This provides a way for
|
||||
* standard settings to be shared across multiple projects.
|
||||
*
|
||||
* If the path starts with "./" or "../", the path is resolved relative to the folder of the file that contains
|
||||
* the "extends" field. Otherwise, the first path segment is interpreted as an NPM package name, and will be
|
||||
* resolved using NodeJS require().
|
||||
*
|
||||
* SUPPORTED TOKENS: none
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
// "extends": "./shared/api-extractor-base.json"
|
||||
// "extends": "my-package/include/api-extractor-base.json"
|
||||
/**
|
||||
* Determines the "<projectFolder>" token that can be used with other config file settings. The project folder
|
||||
* typically contains the tsconfig.json and package.json config files, but the path is user-defined.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting.
|
||||
*
|
||||
* The default value for "projectFolder" is the token "<lookup>", which means the folder is determined by traversing
|
||||
* parent folders, starting from the folder containing api-extractor.json, and stopping at the first folder
|
||||
* that contains a tsconfig.json file. If a tsconfig.json file cannot be found in this way, then an error
|
||||
* will be reported.
|
||||
*
|
||||
* SUPPORTED TOKENS: <lookup>
|
||||
* DEFAULT VALUE: "<lookup>"
|
||||
*/
|
||||
"projectFolder": "../..",
|
||||
/**
|
||||
* (REQUIRED) Specifies the .d.ts file to be used as the starting point for analysis. API Extractor
|
||||
* analyzes the symbols exported by this module.
|
||||
*
|
||||
* The file extension must be ".d.ts" and not ".ts".
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
*/
|
||||
"mainEntryPointFilePath": "<projectFolder>/packages/<unscopedPackageName>/.tsbuild-api/index.d.ts",
|
||||
/**
|
||||
* A list of NPM package names whose exports should be treated as part of this package.
|
||||
*
|
||||
* For example, suppose that Webpack is used to generate a distributed bundle for the project "library1",
|
||||
* and another NPM package "library2" is embedded in this bundle. Some types from library2 may become part
|
||||
* of the exported API for library1, but by default API Extractor would generate a .d.ts rollup that explicitly
|
||||
* imports library2. To avoid this, we can specify:
|
||||
*
|
||||
* "bundledPackages": [ "library2" ],
|
||||
*
|
||||
* This would direct API Extractor to embed those types directly in the .d.ts rollup, as if they had been
|
||||
* local files for library1.
|
||||
*/
|
||||
"bundledPackages": [],
|
||||
/**
|
||||
* Specifies what type of newlines API Extractor should use when writing output files. By default, the output files
|
||||
* will be written with Windows-style newlines. To use POSIX-style newlines, specify "lf" instead.
|
||||
* To use the OS's default newline kind, specify "os".
|
||||
*
|
||||
* DEFAULT VALUE: "crlf"
|
||||
*/
|
||||
"newlineKind": "lf",
|
||||
/**
|
||||
* Set to true when invoking API Extractor's test harness. When `testMode` is true, the `toolVersion` field in the
|
||||
* .api.json file is assigned an empty string to prevent spurious diffs in output files tracked for tests.
|
||||
*
|
||||
* DEFAULT VALUE: "false"
|
||||
*/
|
||||
// "testMode": false,
|
||||
/**
|
||||
* Specifies how API Extractor sorts members of an enum when generating the .api.json file. By default, the output
|
||||
* files will be sorted alphabetically, which is "by-name". To keep the ordering in the source code, specify
|
||||
* "preserve".
|
||||
*
|
||||
* DEFAULT VALUE: "by-name"
|
||||
*/
|
||||
// "enumMemberOrder": "by-name",
|
||||
/**
|
||||
* Determines how the TypeScript compiler engine will be invoked by API Extractor.
|
||||
*/
|
||||
"compiler": {
|
||||
/**
|
||||
* Specifies the path to the tsconfig.json file to be used by API Extractor when analyzing the project.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* Note: This setting will be ignored if "overrideTsconfig" is used.
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/tsconfig.json"
|
||||
*/
|
||||
"tsconfigFilePath": "<projectFolder>/packages/<unscopedPackageName>/tsconfig.json"
|
||||
/**
|
||||
* Provides a compiler configuration that will be used instead of reading the tsconfig.json file from disk.
|
||||
* The object must conform to the TypeScript tsconfig schema:
|
||||
*
|
||||
* http://json.schemastore.org/tsconfig
|
||||
*
|
||||
* If omitted, then the tsconfig.json file will be read from the "projectFolder".
|
||||
*
|
||||
* DEFAULT VALUE: no overrideTsconfig section
|
||||
*/
|
||||
// "overrideTsconfig": {
|
||||
// . . .
|
||||
// }
|
||||
/**
|
||||
* This option causes the compiler to be invoked with the --skipLibCheck option. This option is not recommended
|
||||
* and may cause API Extractor to produce incomplete or incorrect declarations, but it may be required when
|
||||
* dependencies contain declarations that are incompatible with the TypeScript engine that API Extractor uses
|
||||
* for its analysis. Where possible, the underlying issue should be fixed rather than relying on skipLibCheck.
|
||||
*
|
||||
* DEFAULT VALUE: false
|
||||
*/
|
||||
// "skipLibCheck": true,
|
||||
},
|
||||
/**
|
||||
* Configures how the API report file (*.api.md) will be generated.
|
||||
*/
|
||||
"apiReport": {
|
||||
/**
|
||||
* (REQUIRED) Whether to generate an API report.
|
||||
*/
|
||||
"enabled": true,
|
||||
/**
|
||||
* The filename for the API report files. It will be combined with "reportFolder" or "reportTempFolder" to produce
|
||||
* a full file path.
|
||||
*
|
||||
* The file extension should be ".api.md", and the string should not contain a path separator such as "\" or "/".
|
||||
*
|
||||
* SUPPORTED TOKENS: <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<unscopedPackageName>.api.md"
|
||||
*/
|
||||
"reportFileName": "api-report",
|
||||
/**
|
||||
* Specifies the folder where the API report file is written. The file name portion is determined by
|
||||
* the "reportFileName" setting.
|
||||
*
|
||||
* The API report file is normally tracked by Git. Changes to it can be used to trigger a branch policy,
|
||||
* e.g. for an API review.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/temp/"
|
||||
*/
|
||||
"reportFolder": "<projectFolder>/packages/<unscopedPackageName>/",
|
||||
/**
|
||||
* Specifies the folder where the temporary report file is written. The file name portion is determined by
|
||||
* the "reportFileName" setting.
|
||||
*
|
||||
* After the temporary file is written to disk, it is compared with the file in the "reportFolder".
|
||||
* If they are different, a production build will fail.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/temp/"
|
||||
*/
|
||||
"reportTempFolder": "<projectFolder>/packages/<unscopedPackageName>/api/temp"
|
||||
/**
|
||||
* Whether "forgotten exports" should be included in the API report file. Forgotten exports are declarations
|
||||
* flagged with `ae-forgotten-export` warnings. See https://api-extractor.com/pages/messages/ae-forgotten-export/ to
|
||||
* learn more.
|
||||
*
|
||||
* DEFAULT VALUE: "false"
|
||||
*/
|
||||
// "includeForgottenExports": false
|
||||
},
|
||||
/**
|
||||
* Configures how the doc model file (*.api.json) will be generated.
|
||||
*/
|
||||
"docModel": {
|
||||
/**
|
||||
* (REQUIRED) Whether to generate a doc model file.
|
||||
*/
|
||||
"enabled": true,
|
||||
/**
|
||||
* The output path for the doc model file. The file extension should be ".api.json".
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/temp/<unscopedPackageName>.api.json"
|
||||
*/
|
||||
"apiJsonFilePath": "<projectFolder>/packages/<unscopedPackageName>/api/api.json",
|
||||
/**
|
||||
* Whether "forgotten exports" should be included in the doc model file. Forgotten exports are declarations
|
||||
* flagged with `ae-forgotten-export` warnings. See https://api-extractor.com/pages/messages/ae-forgotten-export/ to
|
||||
* learn more.
|
||||
*
|
||||
* DEFAULT VALUE: "false"
|
||||
*/
|
||||
// "includeForgottenExports": false,
|
||||
/**
|
||||
* The base URL where the project's source code can be viewed on a website such as GitHub or
|
||||
* Azure DevOps. This URL path corresponds to the `<projectFolder>` path on disk.
|
||||
*
|
||||
* This URL is concatenated with the file paths serialized to the doc model to produce URL file paths to individual API items.
|
||||
* For example, if the `projectFolderUrl` is "https://github.com/microsoft/rushstack/tree/main/apps/api-extractor" and an API
|
||||
* item's file path is "api/ExtractorConfig.ts", the full URL file path would be
|
||||
* "https://github.com/microsoft/rushstack/tree/main/apps/api-extractor/api/ExtractorConfig.js".
|
||||
*
|
||||
* Can be omitted if you don't need source code links in your API documentation reference.
|
||||
*
|
||||
* SUPPORTED TOKENS: none
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
"projectFolderUrl": "https://github.com/tldraw/tldraw/blob/main"
|
||||
},
|
||||
/**
|
||||
* Configures how the .d.ts rollup file will be generated.
|
||||
*/
|
||||
"dtsRollup": {
|
||||
/**
|
||||
* (REQUIRED) Whether to generate the .d.ts rollup file.
|
||||
*/
|
||||
"enabled": true,
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated without any trimming.
|
||||
* This file will include all declarations that are exported by the main entry point.
|
||||
*
|
||||
* If the path is an empty string, then this file will not be written.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/dist/<unscopedPackageName>.d.ts"
|
||||
*/
|
||||
"untrimmedFilePath": "<projectFolder>/packages/<unscopedPackageName>/api/internal.d.ts",
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated with trimming for an "alpha" release.
|
||||
* This file will include only declarations that are marked as "@public", "@beta", or "@alpha".
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
// "alphaTrimmedFilePath": "0.0.0",
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated with trimming for a "beta" release.
|
||||
* This file will include only declarations that are marked as "@public" or "@beta".
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
// "betaTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-beta.d.ts",
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated with trimming for a "public" release.
|
||||
* This file will include only declarations that are marked as "@public".
|
||||
*
|
||||
* If the path is an empty string, then this file will not be written.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
"publicTrimmedFilePath": "<projectFolder>/packages/<unscopedPackageName>/api/public.d.ts"
|
||||
/**
|
||||
* When a declaration is trimmed, by default it will be replaced by a code comment such as
|
||||
* "Excluded from this release type: exampleMember". Set "omitTrimmingComments" to true to remove the
|
||||
* declaration completely.
|
||||
*
|
||||
* DEFAULT VALUE: false
|
||||
*/
|
||||
// "omitTrimmingComments": true
|
||||
},
|
||||
/**
|
||||
* Configures how the tsdoc-metadata.json file will be generated.
|
||||
*/
|
||||
"tsdocMetadata": {
|
||||
/**
|
||||
* Whether to generate the tsdoc-metadata.json file.
|
||||
*
|
||||
* DEFAULT VALUE: true
|
||||
*/
|
||||
"enabled": false
|
||||
/**
|
||||
* Specifies where the TSDoc metadata file should be written.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* The default value is "<lookup>", which causes the path to be automatically inferred from the "tsdocMetadata",
|
||||
* "typings" or "main" fields of the project's package.json. If none of these fields are set, the lookup
|
||||
* falls back to "tsdoc-metadata.json" in the package folder.
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<lookup>"
|
||||
*/
|
||||
// "tsdocMetadataFilePath": "<projectFolder>/dist/tsdoc-metadata.json"
|
||||
},
|
||||
/**
|
||||
* Configures how API Extractor reports error and warning messages produced during analysis.
|
||||
*
|
||||
* There are three sources of messages: compiler messages, API Extractor messages, and TSDoc messages.
|
||||
*/
|
||||
"messages": {
|
||||
/**
|
||||
* Configures handling of diagnostic messages reported by the TypeScript compiler engine while analyzing
|
||||
* the input .d.ts files.
|
||||
*
|
||||
* TypeScript message identifiers start with "TS" followed by an integer. For example: "TS2551"
|
||||
*
|
||||
* DEFAULT VALUE: A single "default" entry with logLevel=warning.
|
||||
*/
|
||||
"compilerMessageReporting": {
|
||||
/**
|
||||
* Configures the default routing for messages that don't match an explicit rule in this table.
|
||||
*/
|
||||
"default": {
|
||||
/**
|
||||
* Specifies whether the message should be written to the the tool's output log. Note that
|
||||
* the "addToApiReportFile" property may supersede this option.
|
||||
*
|
||||
* Possible values: "error", "warning", "none"
|
||||
*
|
||||
* Errors cause the build to fail and return a nonzero exit code. Warnings cause a production build fail
|
||||
* and return a nonzero exit code. For a non-production build (e.g. when "api-extractor run" includes
|
||||
* the "--local" option), the warning is displayed but the build will not fail.
|
||||
*
|
||||
* DEFAULT VALUE: "warning"
|
||||
*/
|
||||
"logLevel": "error"
|
||||
/**
|
||||
* When addToApiReportFile is true: If API Extractor is configured to write an API report file (.api.md),
|
||||
* then the message will be written inside that file; otherwise, the message is instead logged according to
|
||||
* the "logLevel" option.
|
||||
*
|
||||
* DEFAULT VALUE: false
|
||||
*/
|
||||
// "addToApiReportFile": false
|
||||
}
|
||||
// "TS2551": {
|
||||
// "logLevel": "warning",
|
||||
// "addToApiReportFile": true
|
||||
// },
|
||||
//
|
||||
// . . .
|
||||
},
|
||||
/**
|
||||
* Configures handling of messages reported by API Extractor during its analysis.
|
||||
*
|
||||
* API Extractor message identifiers start with "ae-". For example: "ae-extra-release-tag"
|
||||
*
|
||||
* DEFAULT VALUE: See api-extractor-defaults.json for the complete table of extractorMessageReporting mappings
|
||||
*/
|
||||
"extractorMessageReporting": {
|
||||
"default": {
|
||||
"logLevel": "error"
|
||||
},
|
||||
"ae-internal-missing-underscore": {
|
||||
"logLevel": "none",
|
||||
"addToApiReportFile": false
|
||||
},
|
||||
"ae-forgotten-export": {
|
||||
"logLevel": "error",
|
||||
"addToApiReportFile": false
|
||||
},
|
||||
"ae-wrong-input-file-type": {
|
||||
"logLevel": "none"
|
||||
}
|
||||
// "ae-extra-release-tag": {
|
||||
// "logLevel": "warning",
|
||||
// "addToApiReportFile": true
|
||||
// },
|
||||
//
|
||||
// . . .
|
||||
},
|
||||
/**
|
||||
* Configures handling of messages reported by the TSDoc parser when analyzing code comments.
|
||||
*
|
||||
* TSDoc message identifiers start with "tsdoc-". For example: "tsdoc-link-tag-unescaped-text"
|
||||
*
|
||||
* DEFAULT VALUE: A single "default" entry with logLevel=warning.
|
||||
*/
|
||||
"tsdocMessageReporting": {
|
||||
"default": {
|
||||
"logLevel": "error"
|
||||
// "addToApiReportFile": false
|
||||
}
|
||||
// "tsdoc-link-tag-unescaped-text": {
|
||||
// "logLevel": "warning",
|
||||
// "addToApiReportFile": true
|
||||
// },
|
||||
//
|
||||
// . . .
|
||||
}
|
||||
}
|
||||
}
|
||||
15
internal/config/package.json
Normal file
15
internal/config/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "config",
|
||||
"version": "0.0.0",
|
||||
"main": "index.js",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@jest/expect-utils": "^30.0.5",
|
||||
"@peculiar/webcrypto": "^1.5.0",
|
||||
"jest-matcher-utils": "^30.0.5",
|
||||
"lazyrepo": "0.0.0-alpha.27",
|
||||
"vitest": "^5.0.0",
|
||||
"vitest-canvas-mock": "^1.1.3"
|
||||
}
|
||||
}
|
||||
37
internal/config/tsconfig.base.json
Normal file
37
internal/config/tsconfig.base.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "Default",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"importHelpers": true,
|
||||
"resolveJsonModule": true,
|
||||
"incremental": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUncheckedSideEffectImports": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictNullChecks": true,
|
||||
"useDefineForClassFields": true,
|
||||
"noImplicitOverride": true,
|
||||
"experimentalDecorators": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
6
internal/config/tsconfig.json
Normal file
6
internal/config/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"include": ["vitest"],
|
||||
"exclude": ["node_modules", ".tsbuild*"],
|
||||
"compilerOptions": { "outDir": "./.tsbuild", "rootDir": "." }
|
||||
}
|
||||
51
internal/config/vitest/node-preset.ts
Normal file
51
internal/config/vitest/node-preset.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import path from 'path'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
const svgTransform = {
|
||||
name: 'svg-transform',
|
||||
transform(_code: string, id: string) {
|
||||
if (id.endsWith('.svg')) {
|
||||
return {
|
||||
code: 'export default {};',
|
||||
map: null,
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svgTransform],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
// Persist transformed modules to node_modules/.vitest-cache so reruns skip re-transforming.
|
||||
// Only helps a warm cache: a cold run (fresh clone, cleared node_modules) pays full price.
|
||||
fsModuleCache: true,
|
||||
// Worker threads start much faster than forked processes. Projects needing real process
|
||||
// isolation (e.g. sync-worker, which passes node flags via execArgv) override this.
|
||||
pool: 'threads',
|
||||
setupFiles: [path.resolve(__dirname, './setup.ts')],
|
||||
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||||
exclude: [
|
||||
'**/test/__fixtures__/**',
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/.tsbuild/**',
|
||||
'**/.tsbuild-dev/**',
|
||||
'**/.tsbuild-pub/**',
|
||||
],
|
||||
coverage: {
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: ['**/*.test.*', '**/*.spec.*', '**/test/**', '**/__tests__/**'],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': path.resolve(process.cwd(), 'src'),
|
||||
},
|
||||
},
|
||||
ssr: {
|
||||
noExternal: ['@tiptap/react'],
|
||||
},
|
||||
})
|
||||
143
internal/config/vitest/setup.ts
Normal file
143
internal/config/vitest/setup.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { equals, getObjectSubset, iterableEquality, subsetEquality } from '@jest/expect-utils'
|
||||
import {
|
||||
matcherHint,
|
||||
printDiffOrStringify,
|
||||
printExpected,
|
||||
printReceived,
|
||||
stringify,
|
||||
} from 'jest-matcher-utils'
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
await import('vitest-canvas-mock')
|
||||
}
|
||||
|
||||
// Crypto fallback for environments without a native WebCrypto implementation (e.g. the ai package).
|
||||
// jsdom provides window.crypto with subtle crypto, so this only kicks in elsewhere.
|
||||
if (typeof globalThis.crypto === 'undefined') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { Crypto } = require('@peculiar/webcrypto')
|
||||
globalThis.crypto = new Crypto()
|
||||
}
|
||||
|
||||
// Image.decode polyfill - this should be handled by individual package setups,
|
||||
// but including it here as a fallback
|
||||
if (typeof HTMLImageElement !== 'undefined') {
|
||||
if (!HTMLImageElement.prototype.decode) {
|
||||
HTMLImageElement.prototype.decode = function () {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Path2D polyfills
|
||||
if (typeof Path2D !== 'undefined' && typeof Path2D.prototype.roundRect !== 'function') {
|
||||
Path2D.prototype.roundRect = function (x, y, w, h, _) {
|
||||
this.rect(x, y, w, h)
|
||||
}
|
||||
}
|
||||
|
||||
// CSS.supports polyfill for tests that use color spaces (e.g., highlight shapes)
|
||||
if (typeof CSS === 'undefined') {
|
||||
;(global as any).CSS = {}
|
||||
}
|
||||
if (typeof CSS.supports === 'undefined') {
|
||||
CSS.supports = () => false
|
||||
}
|
||||
|
||||
// Pointer capture polyfill. jsdom implements the PointerEvent constructor but not the pointer
|
||||
// capture model, so setPointerCapture/releasePointerCapture/hasPointerCapture are missing
|
||||
// (https://github.com/jsdom/jsdom/pull/2666). Our canvas event handlers capture the pointer on
|
||||
// pointerdown/up, so stub them out to avoid throwing.
|
||||
if (typeof Element !== 'undefined') {
|
||||
Element.prototype.setPointerCapture ??= function () {
|
||||
// noop
|
||||
}
|
||||
Element.prototype.releasePointerCapture ??= function () {
|
||||
// noop
|
||||
}
|
||||
Element.prototype.hasPointerCapture ??= function () {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Captured before any test file installs fake timers.
|
||||
const realSetTimeout = globalThis.setTimeout
|
||||
const realSetImmediate = globalThis.setImmediate
|
||||
|
||||
// Drain deferred React work before vitest tears down jsdom: exportToSvg unmounts its root in a
|
||||
// setTimeout(0) and every react-dom commit schedules a setImmediate that reads `window.event`.
|
||||
// Landing after teardown, those throw "window is not defined" as an unhandled error (flaky CI).
|
||||
if (typeof window !== 'undefined') {
|
||||
afterAll(async () => {
|
||||
// two passes: pass 1 fires pending timeouts and the setImmediate they enqueue; pass 2 covers
|
||||
// the scheduler re-arming itself when it yields mid-work
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await new Promise((resolve) => realSetTimeout(resolve, 0))
|
||||
await new Promise((resolve) => realSetImmediate(resolve))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function convertNumbersInObject(obj: any, roundToNearest: number): any {
|
||||
if (!obj) return obj
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((x) => convertNumbersInObject(x, roundToNearest))
|
||||
}
|
||||
if (typeof obj === 'number') {
|
||||
// || 0 to avoid -0
|
||||
return Math.round(obj / roundToNearest) * roundToNearest || 0
|
||||
}
|
||||
if (typeof obj !== 'object') {
|
||||
return obj
|
||||
}
|
||||
|
||||
const r: any = {
|
||||
__converted: true,
|
||||
}
|
||||
|
||||
for (const k of Object.keys(obj)) {
|
||||
r[k] = convertNumbersInObject(obj[k], roundToNearest)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
expect.extend({
|
||||
toCloselyMatchObject(actual: any, expected: any, roundToNearest = 0.0001) {
|
||||
const matcherName = 'toCloselyMatchObject'
|
||||
const options = {
|
||||
isNot: this.isNot,
|
||||
promise: this.promise,
|
||||
}
|
||||
|
||||
const EXPECTED_LABEL = 'Expected'
|
||||
const RECEIVED_LABEL = 'Received'
|
||||
|
||||
const newActualObj = convertNumbersInObject(actual, roundToNearest)
|
||||
|
||||
const newExpectedObj = convertNumbersInObject(expected, roundToNearest)
|
||||
|
||||
const pass = equals(newActualObj, newExpectedObj, [iterableEquality, subsetEquality])
|
||||
|
||||
const message = pass
|
||||
? () =>
|
||||
matcherHint(matcherName, undefined, undefined, options) +
|
||||
'\n\n' +
|
||||
`Expected: not ${printExpected(expected)}` +
|
||||
(stringify(expected) !== stringify(actual)
|
||||
? `\nReceived: ${printReceived(actual)}`
|
||||
: '')
|
||||
: () =>
|
||||
matcherHint(matcherName, undefined, undefined, options) +
|
||||
'\n\n' +
|
||||
printDiffOrStringify(
|
||||
expected,
|
||||
getObjectSubset(actual, expected),
|
||||
EXPECTED_LABEL,
|
||||
RECEIVED_LABEL,
|
||||
true
|
||||
)
|
||||
|
||||
return { message, pass }
|
||||
},
|
||||
})
|
||||
83
internal/docs/product-stable-ids.md
Normal file
83
internal/docs/product-stable-ids.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Product stable ids
|
||||
|
||||
Every published package in `packages/` declares which commercial component it belongs to via a `tldraw_product` field in its `package.json`. These stable ids are what legal and sales reference in order forms, so they must be unambiguous and durable: renaming, moving, or refactoring code never changes a stable id.
|
||||
|
||||
## The shape of the taxonomy
|
||||
|
||||
The taxonomy has exactly two levels, matching the order form's "Product in Scope" section:
|
||||
|
||||
- **Products** are the top-level line items — the checkboxes a customer ticks. Today: SDK and the Collaboration module.
|
||||
- **Features** are parts of a product that can be licensed on their own, listed indented beneath their parent. Commenting is a feature of the Collaboration module.
|
||||
|
||||
```
|
||||
[ ] SDK
|
||||
[ ] Sync, Driver, Mermaid, Create tldraw CLI (included, not separately licensed)
|
||||
[ ] Collaboration module
|
||||
[ ] Commenting
|
||||
```
|
||||
|
||||
A feature that isn't premium is _included_ in its parent rather than separately licensable — it appears in the manifest so legal can see what a product contains, but it isn't its own checkbox.
|
||||
|
||||
Individual npm packages are not part of this taxonomy. A package declares the component it implements and inherits that component's entitlement; several packages can (and do) share one stable id. `@tldraw/commenting`, `@tldraw/mentions`, and `@tldraw/sync-collaboration` all implement `tldraw:commenting`, and none of them are order form line items.
|
||||
|
||||
## Why package.json
|
||||
|
||||
- npm publishes `package.json` verbatim and published versions are immutable, so every shipped version carries a permanent record of its stable id and premium status. "What did `@tldraw/commenting@5.3.0` claim to be" is answerable forever via the npm registry (for example `https://registry.npmjs.org/@tldraw/commenting/latest`).
|
||||
- The metadata travels with the package through renames and moves.
|
||||
- The release process already rewrites every package's `package.json`, so no separate manifest file can drift out of date.
|
||||
|
||||
## The `tldraw_product` field
|
||||
|
||||
```json
|
||||
"tldraw_product": {
|
||||
"stableId": "tldraw:commenting",
|
||||
"name": "Commenting",
|
||||
"type": "feature",
|
||||
"parent": "tldraw:collaboration",
|
||||
"premium": true,
|
||||
"licenseFlag": "FEAT_COMMENTING"
|
||||
}
|
||||
```
|
||||
|
||||
- `stableId` — immutable commercial identifier, `tldraw:<kebab-case>`. This is what contracts reference.
|
||||
- `name` — the customer-facing label, matching the wording on the order form. Unlike the stable id this may be reworded.
|
||||
- `type` — `product` (top-level line item) or `feature` (part of a parent).
|
||||
- `parent` — for features, the component they belong to.
|
||||
- `premium` — whether the component is separately licensed rather than part of the standard SDK offering.
|
||||
- `licenseFlag` — for premium components, the license key `FLAGS` bit in `packages/editor/src/lib/license/LicenseManager.ts` that entitles a customer to the component. This is the bridge between order forms and runtime enforcement: a license key whose flags include this bit grants the component.
|
||||
|
||||
## Rules
|
||||
|
||||
- A stable id never changes when code is renamed, moved, split, or refactored. If a package is renamed, the `tldraw_product` field moves with it unchanged.
|
||||
- A new stable id is minted only for a genuinely new commercial component — something sales would list on an order form. Major version bumps do not mint new ids; the order form grants "all versions made available during the Order Form Term", so version entitlement is handled by the term and the license key's expiry, never by the id.
|
||||
- Moving a package to a different component is the expensive operation, because customers have signed order forms citing the old one. When a package is likely to become separately licensable later, give it its own component up front.
|
||||
- All packages sharing a stable id must have identical metadata.
|
||||
- Every published package in `packages/` must have the field. `yarn check-packages` (run in CI) enforces this, validates `licenseFlag` values against `LicenseManager`, checks that every `parent` resolves, and checks that packages pointing at `LICENSE.md` actually ship one.
|
||||
|
||||
## How legal references a component
|
||||
|
||||
Reference components by stable id, for example: "SDK (`tldraw:sdk-core`) and the Collaboration module (`tldraw:collaboration`), including Commenting (`tldraw:commenting`)".
|
||||
|
||||
- `yarn product-manifest` prints the manifest as a JSON tree: products, their features, and the packages implementing each.
|
||||
- For any published version, the npm registry copy of each package's `package.json` is the immutable record.
|
||||
|
||||
## Current components
|
||||
|
||||
Run `yarn product-manifest` for the authoritative list. As of the introduction of this convention:
|
||||
|
||||
| Stable id | Name | Parent | Premium | Packages |
|
||||
| ---------------------- | -------------------- | ---------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `tldraw:sdk-core` | SDK | — | no | `tldraw`, `@tldraw/tldraw`, `@tldraw/editor`, `@tldraw/assets`, `@tldraw/state`, `@tldraw/state-react`, `@tldraw/store`, `@tldraw/tlschema`, `@tldraw/utils`, `@tldraw/validate` |
|
||||
| `tldraw:sync` | Sync | `tldraw:sdk-core` | no | `@tldraw/sync`, `@tldraw/sync-core` |
|
||||
| `tldraw:driver` | Driver | `tldraw:sdk-core` | no | `@tldraw/driver` |
|
||||
| `tldraw:mermaid` | Mermaid | `tldraw:sdk-core` | no | `@tldraw/mermaid` |
|
||||
| `tldraw:create-tldraw` | Create tldraw CLI | `tldraw:sdk-core` | no | `create-tldraw` |
|
||||
| `tldraw:collaboration` | Collaboration module | — | yes (`FEAT_COLLABORATION`) | `@tldraw/collaboration` |
|
||||
| `tldraw:commenting` | Commenting | `tldraw:collaboration` | yes (`FEAT_COMMENTING`) | `@tldraw/commenting`, `@tldraw/mentions`, `@tldraw/sync-collaboration` |
|
||||
|
||||
Entitlement flows down the tree:
|
||||
|
||||
- `FEAT_COLLABORATION` — the umbrella flag; grants the whole Collaboration module.
|
||||
- `FEAT_COMMENTING` — grants commenting.
|
||||
|
||||
`@tldraw/mentions` implements commenting today because `@tldraw/commenting` depends on it, so every customer entitled to commenting is entitled to mentions. If mentions later becomes usable on its own — in shape rich text, say — it will need its own component and license flag at that point.
|
||||
77
internal/grafana/README.md
Normal file
77
internal/grafana/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Grafana dashboards and alerts
|
||||
|
||||
**TL;DR:** `resources/` is the source of truth for our Grafana Cloud dashboards and alert rules. CI pushes it with `gcx` on every merge to `main` that touches these files. Edit here, not in Grafana — UI edits get overwritten by the next deploy.
|
||||
|
||||
This directory currently provisions:
|
||||
|
||||
- Eleven dashboards: `Zero on Fly.io — service health` (`zero-fly-health`), `Zero slow queries` (`slow-queries`), and `Zero-cache HTTP edge (Fly.io)` (`ni7hhgd`) in the `Zero` folder; `Anthropic scrape overview` in the `Anthropic` folder; `Dotcom events` (`ni5k8zc`), `Dotcom events V1` (`adgumkhou3chsd`), `File effect outbox`, `MCP server (shared boards)` (`mcp-server`), `MCP app sessions` (`kotx6wj`), and `Bemo analytics` in the `Dotcom` folder; and `Cloudflare platform metrics` (`nivs2rl`), which sits at the root rather than in a folder. The events pipeline currently reports from **staging** — set the environment variable accordingly when a dashboard looks empty.
|
||||
- All 13 Grafana-managed alert rules (Zero replication/errors/backups, Fly.io container health, Anthropic cost/usage, file room DO exceptions)
|
||||
- The folders `Zero` (dashboards + 9 rules), `Dotcom` (6 dashboards + 1 rule), and `Anthropic` (1 dashboard + 3 rules, see below)
|
||||
- The `discord` and `email` notification templates (`templates/*.gotmpl`, defining `tldraw.discord.title` / `tldraw.discord.text` and `tldraw.email.subject` / `tldraw.email.message`), which compress alert notifications to severity + summary + links (no Silence link on Discord: silence URLs carry one matcher per label and blow Discord’s 2000-char message cap on multi-instance alerts, truncating mid-link) and explain `DatasourceNoData` as a likely metrics-source outage. Templates can't go through `gcx resources push` — gcx ignores the `notifications.alerting.grafana.app` API group — so they live in `templates/` instead of `resources/` and the deploy workflow PUTs them through the classic provisioning API. Each contact point's optional Title/Subject and Message fields reference these templates; those references are set by hand (contact points stay hand-managed), so the templates only take effect while a contact point points at them.
|
||||
|
||||
There are two MCP dashboards, for two different servers. `MCP server (shared boards)` covers the public server on the sync worker at `POST /app/mcp` (`apps/dotcom/sync-worker`), which reads its protocol metrics from the `MEASURE` dataset. `MCP app sessions` covers the separate tldraw MCP app worker (`apps/mcp-app`) and its own `MCP_ANALYTICS` dataset. Neither one's panels belong on `Dotcom events` — that dashboard covers the sync worker's own events.
|
||||
|
||||
`Dotcom events V1` holds only the catch-all `Room events` panel and `max attempts before persistence success`; new panels go on `Dotcom events`.
|
||||
|
||||
Contact points, notification policies, and data sources are hand-managed in Grafana on purpose.
|
||||
|
||||
## Conventions
|
||||
|
||||
Every provisioned dashboard carries the tags `provisioned` and `repo:tldraw/tldraw`, so anyone looking at it in Grafana knows where it's managed from. Add both tags when adopting a new dashboard into this tree.
|
||||
|
||||
## Anthropic rules
|
||||
|
||||
The three rules in the `Anthropic` folder watch the Grafana Anthropic integration's scrape of the Anthropic Admin API. The two metrics behave very differently, and the rules are shaped around that:
|
||||
|
||||
- `gen_ai_usage_tokens_total` is near real time and is labelled by API key, model, and token type (`input`, `output`, `cache_creation_5m`, `cache_creation_1h`, `input_cache_read`). About 70 series at the time of writing. This is the only intraday signal.
|
||||
- `gen_ai_cost` is **month-to-date** spend in cents, with no API key label, updated **once a day at about 03:00 UTC**. It resets on the 1st, and has had no data at all on the 1st. Anything built on it describes the last completed day, sees a runaway up to 27 hours late, and is blind for a day or two at each month rollover.
|
||||
|
||||
`AnthropicTokenBurst` (warning) fires when the summed rate across all series, excluding cache reads, exceeds 1000 tokens/sec over an hour (about 3.6M tokens/hour). Three deliberate choices, each learned the hard way:
|
||||
|
||||
- **Summed, not per series.** A per-series rule makes one alert instance per key × model × token type, and every lightly used series has a tiny baseline that any real session clears.
|
||||
- **No baseline.** Usage is bursty and mostly idle (the median hour is under 10 tokens/sec), so a 7-day average is close to zero and "5x the average" is a normal working hour. #10579 and #10582 tuned the multiplier and floor twice and it still paged on ordinary sessions.
|
||||
- **Cache reads excluded.** They cost a tenth of uncached input and dominate agent and eval traffic. On 2026-09-01 and 09-02 the rule paged repeatedly on Opus 5 cache reads from the internal evals key, which spent about $50 that day. The floor is chosen so that workload stays quiet (it peaks around 500 non-cache-read tokens/sec) while a big-numbers bulk cold-outreach research run (`tldraw-internal`, `crm-dashboard-api`), roughly 2000 tokens/sec of uncached Sonnet 4.5 input at $20 to $25 an hour and about $500 over 2026-08-21 to 08-22, clears it within the hour. Those runs are expected work, so a page from this rule usually means "check whether someone started a bulk run" rather than "something is broken".
|
||||
|
||||
`AnthropicDailyCostSpike` (warning) fires when the last completed day cost more than 1.5x the day before, with a $50 floor. `AnthropicHighCostThreshold` (critical) fires when the last completed day cost more than $1000. Both use `increase(gen_ai_cost[1d])` rather than `offset` subtraction so the monthly reset reads as a counter reset and counts from zero instead of going negative. The scrape gap on the 1st is still a blind spot: the first day of the month reads as roughly $0 because there is nothing to diff against. Because the metric only steps once a day, a firing rule stays firing until the next 03:00 update.
|
||||
|
||||
All three are filter queries (`expr > threshold`), so the alert series only exists while the condition holds. `missingSeriesEvalsToResolve: 10` is what stops a single quiet minute from resolving and re-firing them.
|
||||
|
||||
## Making a change
|
||||
|
||||
1. Edit the files under `resources/`.
|
||||
2. Open a PR. CI validates the resources and dry-runs the push so you can see what would change. (The alerting API doesn't support server-side dry-run, so alert rules show as "skipped: client-side check only" — that's expected, not an error.)
|
||||
3. Merge. The deploy workflow (`.github/workflows/deploy-grafana.yml`) pushes to Grafana Cloud.
|
||||
|
||||
## Adding a new alert rule
|
||||
|
||||
A rule file carrying `grafana.com/group` labels cannot be _created_ by the push. Grafana rejects it with `cannot set group when creating a new rule` — the resource API can only update a rule that is already in a group. Nothing catches this earlier: alerting has no server-side dry-run, so the PR reports the rule as "skipped" and goes green.
|
||||
|
||||
Bootstrap the group once by hand, then let the push adopt it:
|
||||
|
||||
gcx api /api/v1/provisioning/folder/<folder uid>/rule-groups/<group> \
|
||||
-X PUT -H 'X-Disable-Provenance: true' -d @group.json
|
||||
|
||||
`group.json` uses the classic provisioning shape (`{title, folderUid, interval, rules: [...]}`), which is not the shape of the file in this tree: `spec.expressions` becomes a `data` array, the expression carrying `source: true` becomes `condition`, and `spec.trigger.interval` becomes the group's `interval` in seconds. `X-Disable-Provenance: true` is what leaves provenance clear so the push can take ownership afterwards.
|
||||
|
||||
`dotcom-file-do-exceptions` was created this way on 2026-08-18, after PR #10052's deploy failed on it.
|
||||
|
||||
## Pulling fresh state
|
||||
|
||||
If you prototyped a dashboard in the Grafana UI and want to adopt it, pull it into the tree with a local gcx context (`gcx login`):
|
||||
|
||||
gcx resources pull dashboards/<uid> -p internal/grafana/resources -o yaml
|
||||
gcx resources pull alertrules -p internal/grafana/resources -o yaml
|
||||
|
||||
Note the alert rules selector is `alertrules` — bare `rules` routes to the Asserts product and 403s on our stack.
|
||||
|
||||
Don't hand-reformat gcx output; it needs to round-trip. Exception: `zero-fly-health` is stored as JSON because gcx 1.0.0's YAML writer emits an invalid escape for emoji variation selectors in row titles (its own reader then rejects the file). If a pulled YAML file fails `gcx resources validate`, re-pull that resource with `-o json`.
|
||||
|
||||
Alert rules created through Grafana's "import Prometheus rules" flow can't be pushed: they carry `converted_prometheus` provenance, and Grafana only accepts writes to a rule from the tool that owns its provenance. To adopt one, strip the `alerting.grafana.app/prometheus-rule-definition` annotation and set `grafana.com/provenance: ''` in the pulled file, then recreate the rule in Grafana with clear provenance so the push can take ownership: delete the imported group (`gcx api /api/convert/prometheus/config/v1/rules/<folder title>/<group> -X DELETE`), recreate it via `gcx api /api/v1/provisioning/folder/<folder uid>/rule-groups/<group> -X PUT -H 'X-Disable-Provenance: true' -d @group.json`, and push. Recreating grouped rules must go through that group endpoint — the resource API gcx pushes with can't add rules to groups, only update rules already in one. The three Anthropic cost rules went through this adoption on 2026-08-10 and are repo-owned now — don't re-run the integration's rule import for them, which would recreate the imported group alongside the adopted one and bring the provenance clash back.
|
||||
|
||||
## Deleting a resource
|
||||
|
||||
Deleting a file here does **not** delete it in Grafana. Remove the file and run `gcx resources delete <type>/<uid>` manually.
|
||||
|
||||
## Service account access
|
||||
|
||||
CI authenticates with a Grafana service account token (`GRAFANA_SERVER` / `GRAFANA_TOKEN` repo secrets). The `zero-fly-health` dashboard has restricted permissions: the service account needs an explicit grant on it (or admin role) or pulls and pushes will 403.
|
||||
@@ -0,0 +1,112 @@
|
||||
apiVersion: rules.alerting.grafana.app/v0alpha1
|
||||
kind: AlertRule
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: anthropic
|
||||
grafana.com/provenance: ''
|
||||
grafana.com/updateTimestamp: '2026-03-20T11:22:55Z'
|
||||
grafana.com/updatedBy: bf1wptf89bpq8d
|
||||
labels:
|
||||
grafana.app/folder: anthropic
|
||||
grafana.com/group: anthropic
|
||||
grafana.com/group-index: '1'
|
||||
name: 8451fbba-5835-56e2-b2f5-7f5e44c520b8
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
annotations:
|
||||
description: 'Anthropic API usage over the last hour averaged {{ $value | humanize }}
|
||||
tokens/sec excluding cache reads, above the 1000 tokens/sec floor (about 3.6M
|
||||
tokens/hour). Check the scrape overview dashboard for which key and model.'
|
||||
summary: Anthropic API token burst detected.
|
||||
execErrState: Ok
|
||||
expressions:
|
||||
prometheus_math:
|
||||
model:
|
||||
datasource:
|
||||
IsPrunable: false
|
||||
access: ''
|
||||
apiVersion: ''
|
||||
basicAuth: false
|
||||
basicAuthUser: ''
|
||||
created: '0001-01-01T00:00:00Z'
|
||||
database: ''
|
||||
id: -100
|
||||
isDefault: false
|
||||
jsonData: {}
|
||||
name: __expr__
|
||||
readOnly: false
|
||||
secureJsonData: {}
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
updated: '0001-01-01T00:00:00Z'
|
||||
url: ''
|
||||
user: ''
|
||||
withCredentials: false
|
||||
expression: is_number($query) || is_nan($query) || is_inf($query)
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: prometheus_math
|
||||
type: math
|
||||
queryType: math
|
||||
query:
|
||||
datasourceUID: grafanacloud-prom
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: grafanacloud-prom
|
||||
expr: |
|
||||
sum(
|
||||
rate(gen_ai_usage_tokens_total{job=~"integrations/anthropic/.*", gen_ai_token_type!="input_cache_read"}[1h])
|
||||
) > 1000
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
range: false
|
||||
refId: query
|
||||
queryType: prometheus
|
||||
relativeTimeRange:
|
||||
from: 11m0s
|
||||
to: 1m0s
|
||||
threshold:
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 0
|
||||
type: gt
|
||||
datasource:
|
||||
IsPrunable: false
|
||||
access: ''
|
||||
apiVersion: ''
|
||||
basicAuth: false
|
||||
basicAuthUser: ''
|
||||
created: '0001-01-01T00:00:00Z'
|
||||
database: ''
|
||||
id: -100
|
||||
isDefault: false
|
||||
jsonData: {}
|
||||
name: __expr__
|
||||
readOnly: false
|
||||
secureJsonData: {}
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
updated: '0001-01-01T00:00:00Z'
|
||||
url: ''
|
||||
user: ''
|
||||
withCredentials: false
|
||||
expression: prometheus_math
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: threshold
|
||||
type: threshold
|
||||
queryType: threshold
|
||||
source: true
|
||||
for: 15m0s
|
||||
labels:
|
||||
__converted_prometheus_rule__: 'true'
|
||||
severity: warning
|
||||
missingSeriesEvalsToResolve: 10
|
||||
noDataState: Ok
|
||||
title: AnthropicTokenBurst
|
||||
trigger:
|
||||
interval: 1m
|
||||
@@ -0,0 +1,66 @@
|
||||
apiVersion: rules.alerting.grafana.app/v0alpha1
|
||||
kind: AlertRule
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: f6hsw6
|
||||
grafana.com/provenance: ''
|
||||
grafana.com/updateTimestamp: '2026-04-21T09:07:11Z'
|
||||
grafana.com/updatedBy: ae5t47muqbegwf
|
||||
labels:
|
||||
grafana.app/folder: f6hsw6
|
||||
grafana.com/group: zero-alerts-fast
|
||||
grafana.com/group-index: '2'
|
||||
name: afjn4kz1g4jk0a
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
execErrState: Error
|
||||
expressions:
|
||||
A:
|
||||
datasourceUID: grafanacloud-prom
|
||||
model:
|
||||
editorMode: code
|
||||
expr: zero_replication_total_lag_millisecond{deployment_environment="production",
|
||||
service_name="zero-rm"}
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
legendFormat: __auto
|
||||
maxDataPoints: 43200
|
||||
range: false
|
||||
refId: A
|
||||
relativeTimeRange:
|
||||
from: 10m0s
|
||||
to: 0s
|
||||
C:
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 1000
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- C
|
||||
reducer:
|
||||
params: []
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
queryType: expression
|
||||
source: true
|
||||
for: 5m0s
|
||||
labels:
|
||||
service: zero
|
||||
severity: warning
|
||||
noDataState: NoData
|
||||
title: Zero replication lag elevated
|
||||
trigger:
|
||||
interval: 5m
|
||||
@@ -0,0 +1,81 @@
|
||||
apiVersion: rules.alerting.grafana.app/v0alpha1
|
||||
kind: AlertRule
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: f6hsw6
|
||||
grafana.com/provenance: ''
|
||||
grafana.com/updateTimestamp: '2026-04-28T08:48:46Z'
|
||||
grafana.com/updatedBy: ae5t47muqbegwf
|
||||
labels:
|
||||
grafana.app/folder: f6hsw6
|
||||
grafana.com/group: zero-fly-alerts
|
||||
grafana.com/group-index: '5'
|
||||
name: bfjyilovht2pse
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
annotations:
|
||||
description: |-
|
||||
Fires when the 5-minute rolling count of logs with detected_level error or fatal is greater than zero for service_name matching zero-(rm|vs) and deployment_environment=production.
|
||||
|
||||
Investigate in Grafana Explore (grafanacloud-tldraw-logs):
|
||||
|
||||
{service_name=~"zero-(rm|vs)", deployment_environment="production"} | detected_level=~"error|fatal"
|
||||
|
||||
Typical causes include failed upstream connections, auth/sync failures, or crashes; use log fields (component, worker, status, message) to narrow the blast radius.
|
||||
summary: 'Production Zero: error/fatal logs in the last evaluation window.'
|
||||
execErrState: Ok
|
||||
expressions:
|
||||
A:
|
||||
datasourceUID: grafanacloud-logs
|
||||
model:
|
||||
datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
editorMode: code
|
||||
expr:
|
||||
sum(count_over_time({service_name=~"zero-(rm|vs)", deployment_environment="production"}
|
||||
| detected_level=~"error|fatal" [5m]))
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
queryType: instant
|
||||
refId: A
|
||||
queryType: instant
|
||||
relativeTimeRange:
|
||||
from: 10m0s
|
||||
to: 0s
|
||||
C:
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 0
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- C
|
||||
reducer:
|
||||
params: []
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
queryType: expression
|
||||
source: true
|
||||
for: 1m0s
|
||||
labels:
|
||||
service: zero
|
||||
severity: critical
|
||||
noDataState: Ok
|
||||
paused: true
|
||||
title: Zero error log (production)
|
||||
trigger:
|
||||
interval: 1m
|
||||
@@ -0,0 +1,72 @@
|
||||
apiVersion: rules.alerting.grafana.app/v0alpha1
|
||||
kind: AlertRule
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: f6hsw6
|
||||
grafana.com/provenance: ''
|
||||
grafana.com/updateTimestamp: '2026-04-28T08:48:46Z'
|
||||
grafana.com/updatedBy: ae5t47muqbegwf
|
||||
labels:
|
||||
grafana.app/folder: f6hsw6
|
||||
grafana.com/group: zero-fly-alerts
|
||||
grafana.com/group-index: '2'
|
||||
name: cfjnb1whljcowe
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
annotations:
|
||||
summary: Zero container disk utilization exceeds 80%
|
||||
execErrState: Error
|
||||
expressions:
|
||||
A:
|
||||
datasourceUID: afj5re4yk9og0e
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: afj5re4yk9og0e
|
||||
editorMode: code
|
||||
expr: |-
|
||||
(fly_instance_filesystem_blocks{app=~"production-zero-rm|production-zero-vs"} - fly_instance_filesystem_blocks_avail{app=~"production-zero-rm|production-zero-vs"}) * fly_instance_filesystem_block_size{app=~"production-zero-rm|production-zero-vs"} /
|
||||
(fly_instance_filesystem_blocks{app=~"production-zero-rm|production-zero-vs"} * fly_instance_filesystem_block_size{app=~"production-zero-rm|production-zero-vs"})
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
legendFormat: __auto
|
||||
maxDataPoints: 43200
|
||||
range: false
|
||||
refId: A
|
||||
relativeTimeRange:
|
||||
from: 10m0s
|
||||
to: 0s
|
||||
C:
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 0.8
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- C
|
||||
reducer:
|
||||
params: []
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
queryType: expression
|
||||
source: true
|
||||
for: 10m0s
|
||||
labels:
|
||||
service: zero
|
||||
severity: critical
|
||||
noDataState: NoData
|
||||
title: Flyio zero container disk high
|
||||
trigger:
|
||||
interval: 1m
|
||||
@@ -0,0 +1,72 @@
|
||||
apiVersion: rules.alerting.grafana.app/v0alpha1
|
||||
kind: AlertRule
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: f6hsw6
|
||||
grafana.com/provenance: ''
|
||||
grafana.com/updateTimestamp: '2026-04-28T08:48:46Z'
|
||||
grafana.com/updatedBy: ae5t47muqbegwf
|
||||
labels:
|
||||
grafana.app/folder: f6hsw6
|
||||
grafana.com/group: zero-fly-alerts
|
||||
grafana.com/group-index: '3'
|
||||
name: cfjnbehzmlhxcc
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
annotations:
|
||||
summary: Zero container CPU utilization exceeds 85%
|
||||
execErrState: Error
|
||||
expressions:
|
||||
A:
|
||||
datasourceUID: afj5re4yk9og0e
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: afj5re4yk9og0e
|
||||
editorMode: code
|
||||
expr:
|
||||
sum by (instance, app)(rate(fly_instance_cpu{app=~"production-zero-rm|production-zero-vs",mode!="idle"}[5m]))
|
||||
/ sum by (instance, app)(rate(fly_instance_cpu{app=~"production-zero-rm|production-zero-vs"}[5m]))
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
legendFormat: __auto
|
||||
maxDataPoints: 43200
|
||||
range: false
|
||||
refId: A
|
||||
relativeTimeRange:
|
||||
from: 10m0s
|
||||
to: 0s
|
||||
C:
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 0.85
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- C
|
||||
reducer:
|
||||
params: []
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
queryType: expression
|
||||
source: true
|
||||
for: 15m0s
|
||||
labels:
|
||||
service: zero
|
||||
severity: warning
|
||||
noDataState: NoData
|
||||
title: Flyio zero container cpu high
|
||||
trigger:
|
||||
interval: 1m
|
||||
@@ -0,0 +1,80 @@
|
||||
apiVersion: rules.alerting.grafana.app/v0alpha1
|
||||
kind: AlertRule
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: f6hsw6
|
||||
grafana.com/provenance: ''
|
||||
grafana.com/updateTimestamp: '2026-04-28T08:48:46Z'
|
||||
grafana.com/updatedBy: ae5t47muqbegwf
|
||||
labels:
|
||||
grafana.app/folder: f6hsw6
|
||||
grafana.com/group: zero-fly-alerts
|
||||
grafana.com/group-index: '6'
|
||||
name: cfjyl9tbrury8b
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
annotations:
|
||||
description: |-
|
||||
Fires when the 5-minute rolling count of logs with detected_level error or fatal is greater than zero for service_name matching zero-(rm|vs) and deployment_environment=staging.
|
||||
|
||||
Investigate in Grafana Explore (grafanacloud-tldraw-logs):
|
||||
|
||||
{service_name=~"zero-(rm|vs)", deployment_environment="staging"} | detected_level=~"error|fatal"
|
||||
|
||||
Typical causes include failed upstream connections, auth/sync failures, or crashes; use log fields (component, worker, status, message) to narrow the blast radius.
|
||||
summary: 'Staging Zero: error/fatal logs in the last evaluation window.'
|
||||
execErrState: Ok
|
||||
expressions:
|
||||
A:
|
||||
datasourceUID: grafanacloud-logs
|
||||
model:
|
||||
datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
editorMode: code
|
||||
expr: sum(count_over_time({service_name=~"zero-(rm|vs)", deployment_environment="staging"}
|
||||
| detected_level=~"error|fatal" [5m]))
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
queryType: instant
|
||||
refId: A
|
||||
queryType: instant
|
||||
relativeTimeRange:
|
||||
from: 10m0s
|
||||
to: 0s
|
||||
C:
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 0
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- C
|
||||
reducer:
|
||||
params: []
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
queryType: expression
|
||||
source: true
|
||||
for: 5m0s
|
||||
labels:
|
||||
service: zero
|
||||
severity: warning
|
||||
noDataState: Ok
|
||||
paused: true
|
||||
title: Zero error log (staging)
|
||||
trigger:
|
||||
interval: 1m
|
||||
@@ -0,0 +1,69 @@
|
||||
apiVersion: rules.alerting.grafana.app/v0alpha1
|
||||
kind: AlertRule
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: f6hsw6
|
||||
grafana.com/provenance: ''
|
||||
grafana.com/updateTimestamp: '2026-04-21T09:06:29Z'
|
||||
grafana.com/updatedBy: ae5t47muqbegwf
|
||||
labels:
|
||||
grafana.app/folder: f6hsw6
|
||||
grafana.com/group: zero-alerts
|
||||
grafana.com/group-index: '1'
|
||||
name: dfjn3qe3l7w8wc
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
execErrState: Error
|
||||
expressions:
|
||||
A:
|
||||
datasourceUID: grafanacloud-prom
|
||||
model:
|
||||
editorMode: code
|
||||
expr: zero_replica_backup_lag_millisecond{deployment_environment="production",
|
||||
service_name="zero-rm"}
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
legendFormat: __auto
|
||||
maxDataPoints: 43200
|
||||
range: false
|
||||
refId: A
|
||||
relativeTimeRange:
|
||||
from: 10m0s
|
||||
to: 0s
|
||||
C:
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 1200000
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- C
|
||||
reducer:
|
||||
params: []
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
queryType: expression
|
||||
source: true
|
||||
for: 20m0s
|
||||
labels:
|
||||
service: zero
|
||||
severity: critical
|
||||
noDataState: NoData
|
||||
notificationSettings:
|
||||
receiver: Discord
|
||||
type: SimplifiedRouting
|
||||
title: Zero replica backup lag
|
||||
trigger:
|
||||
interval: 20m
|
||||
@@ -0,0 +1,72 @@
|
||||
apiVersion: rules.alerting.grafana.app/v0alpha1
|
||||
kind: AlertRule
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: f6hsw6
|
||||
grafana.com/provenance: ''
|
||||
grafana.com/updateTimestamp: '2026-04-28T08:48:46Z'
|
||||
grafana.com/updatedBy: ae5t47muqbegwf
|
||||
labels:
|
||||
grafana.app/folder: f6hsw6
|
||||
grafana.com/group: zero-fly-alerts
|
||||
grafana.com/group-index: '1'
|
||||
name: dfjn9xellhibka
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
annotations:
|
||||
summary: Zero container memory utilization exceeds 90%
|
||||
execErrState: Error
|
||||
expressions:
|
||||
A:
|
||||
datasourceUID: afj5re4yk9og0e
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: afj5re4yk9og0e
|
||||
editorMode: code
|
||||
expr: 1 - (fly_instance_memory_mem_available{app=~"production-zero-rm|production-zero-vs"}
|
||||
/ fly_instance_memory_mem_total{app=~"production-zero-rm|production-zero-vs"})
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
legendFormat: __auto
|
||||
maxDataPoints: 43200
|
||||
range: false
|
||||
refId: A
|
||||
relativeTimeRange:
|
||||
from: 10m0s
|
||||
to: 0s
|
||||
C:
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 0.9
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- C
|
||||
reducer:
|
||||
params: []
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
queryType: expression
|
||||
source: true
|
||||
for: 1m0s
|
||||
keepFiringFor: 5m0s
|
||||
labels:
|
||||
service: zero
|
||||
severity: warning
|
||||
noDataState: NoData
|
||||
title: Flyio zero container memory high
|
||||
trigger:
|
||||
interval: 1m
|
||||
@@ -0,0 +1,94 @@
|
||||
apiVersion: rules.alerting.grafana.app/v0alpha1
|
||||
kind: AlertRule
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: dotcom
|
||||
grafana.com/provenance: ''
|
||||
labels:
|
||||
grafana.app/folder: dotcom
|
||||
grafana.com/group: dotcom-alerts
|
||||
grafana.com/group-index: '1'
|
||||
name: dotcom-file-do-exceptions
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
annotations:
|
||||
description: TLFileDurableObject threw more than 300 uncaught exceptions in the
|
||||
last hour. Quiet hours run 1-50; the bursts that followed hibernation being
|
||||
enabled on 2026-08-13 run 700-1300. Check the "DO exceptions by namespace"
|
||||
panel on the Cloudflare platform metrics dashboard, then Sentry for the
|
||||
matching source tag.
|
||||
summary: File room durable objects are throwing uncaught exceptions
|
||||
execErrState: Error
|
||||
expressions:
|
||||
A:
|
||||
datasourceUID: ffuyblsuemj28b
|
||||
model:
|
||||
columns:
|
||||
- selector: sum.requests
|
||||
text: exceptions
|
||||
type: number
|
||||
format: table
|
||||
global_query_id: ''
|
||||
parser: backend
|
||||
refId: A
|
||||
root_selector: data.viewer.accounts[0].durableObjectsInvocationsAdaptiveGroups
|
||||
source: url
|
||||
type: graphql
|
||||
url: https://api.cloudflare.com/client/v4/graphql
|
||||
url_options:
|
||||
body_content_type: application/json
|
||||
body_graphql_query: |-
|
||||
query {
|
||||
viewer {
|
||||
accounts(filter: {accountTag: "c34edc4e76350954b63adebde86d5eb1"}) {
|
||||
durableObjectsInvocationsAdaptiveGroups(limit: 1, filter: {datetime_geq: "${__from:date:iso}", datetime_leq: "${__to:date:iso}", namespaceId: "5864db4344ac4c55bfd94e81dd25a043", status: "scriptThrewException"}) {
|
||||
sum { requests }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
body_type: graphql
|
||||
method: POST
|
||||
relativeTimeRange:
|
||||
from: 1h0m0s
|
||||
to: 0s
|
||||
C:
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 300
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- C
|
||||
reducer:
|
||||
params: []
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
queryType: expression
|
||||
source: true
|
||||
for: 10m0s
|
||||
labels:
|
||||
service: dotcom
|
||||
severity: warning
|
||||
# The query filters to scriptThrewException, so a healthy hour returns no rows at
|
||||
# all rather than a zero. NoData here means "nothing threw" — alerting on it would
|
||||
# invert the rule.
|
||||
noDataState: Ok
|
||||
notificationSettings:
|
||||
receiver: Discord
|
||||
type: SimplifiedRouting
|
||||
title: File room DO exceptions
|
||||
trigger:
|
||||
interval: 10m
|
||||
@@ -0,0 +1,95 @@
|
||||
apiVersion: rules.alerting.grafana.app/v0alpha1
|
||||
kind: AlertRule
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: dotcom
|
||||
grafana.com/provenance: ''
|
||||
labels:
|
||||
grafana.app/folder: dotcom
|
||||
grafana.com/group: version-chain-alerts
|
||||
grafana.com/group-index: '1'
|
||||
name: dotcom-version-chain-verify-failures
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
annotations:
|
||||
description: More than two retired version chains failed to reproduce the state
|
||||
the durable object persisted, in the last hour. Check the "Verify results"
|
||||
panel on the Dotcom events dashboard for the reason. head-mismatch is the
|
||||
serious one — the delta encoding is producing a different board — while
|
||||
missing and legacy-fallback mean the chain did not answer at all. Until the
|
||||
legacy full copies are turned off these versions still read; after that they
|
||||
are unreadable and there is no rebuild path, so this should block the
|
||||
rollout advancing.
|
||||
summary: Version chain reconstructions are not reproducing persisted state
|
||||
execErrState: Error
|
||||
expressions:
|
||||
A:
|
||||
datasourceUID: bdgu7tk03irr4e
|
||||
model:
|
||||
dateTimeType: DATETIME
|
||||
editorMode: code
|
||||
extrapolate: true
|
||||
format: table
|
||||
formatAs: table
|
||||
intervalFactor: 1
|
||||
# rawQuery must be the boolean, not a copy of the SQL. The dashboard panels
|
||||
# carry the SQL in both fields, but that shape reaches the plugin as
|
||||
# already-interpolated text and dies on "unsupported value type: $from".
|
||||
# Matches 'fail' rather than negating 'ok': a check the durable object
|
||||
# declined to run (outcome 'skipped') is not a failing chain, and on a
|
||||
# board big enough to skip, a negated match would page on every retire.
|
||||
query: |-
|
||||
SELECT count() as fails
|
||||
FROM 'MEASURE'
|
||||
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
|
||||
AND blob1 = 'version_chain_verify'
|
||||
AND blob2 = 'production-tldraw-multiplayer'
|
||||
AND blob3 = 'fail'
|
||||
rawQuery: true
|
||||
refId: A
|
||||
round: 0s
|
||||
skip_comments: true
|
||||
step: ''
|
||||
relativeTimeRange:
|
||||
from: 1h0m0s
|
||||
to: 0s
|
||||
C:
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 2
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- C
|
||||
reducer:
|
||||
params: []
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
queryType: expression
|
||||
source: true
|
||||
for: 10m0s
|
||||
labels:
|
||||
service: dotcom
|
||||
severity: warning
|
||||
# count() with no GROUP BY returns a row holding 0 rather than no rows, so a
|
||||
# healthy hour evaluates a real zero. NoData here means the Analytics Engine
|
||||
# datasource itself is down, which is not a version chain problem.
|
||||
noDataState: Ok
|
||||
notificationSettings:
|
||||
receiver: Discord
|
||||
type: SimplifiedRouting
|
||||
title: Version chain verify failures
|
||||
trigger:
|
||||
interval: 10m
|
||||
@@ -0,0 +1,85 @@
|
||||
apiVersion: rules.alerting.grafana.app/v0alpha1
|
||||
kind: AlertRule
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: dotcom
|
||||
grafana.com/provenance: ''
|
||||
labels:
|
||||
grafana.app/folder: dotcom
|
||||
grafana.com/group: version-chain-alerts
|
||||
grafana.com/group-index: '2'
|
||||
name: dotcom-version-chain-write-errors
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
annotations:
|
||||
description: A chain write failed non-transiently in the last hour. These have
|
||||
already retried three times and fallen back to writing a legacy full copy,
|
||||
so no version is lost and boards are unaffected — but the chain is unhealthy
|
||||
and the rollout should not advance while this fires. Check the "Chain write
|
||||
errors" panel on the Dotcom events dashboard, then Sentry for the underlying
|
||||
exception. Expected rate is zero.
|
||||
summary: Version chain writes are failing and falling back to legacy copies
|
||||
execErrState: Error
|
||||
expressions:
|
||||
A:
|
||||
datasourceUID: bdgu7tk03irr4e
|
||||
model:
|
||||
dateTimeType: DATETIME
|
||||
editorMode: code
|
||||
extrapolate: true
|
||||
format: table
|
||||
formatAs: table
|
||||
intervalFactor: 1
|
||||
# See the verify-failures rule: rawQuery is the boolean, and the SQL lives
|
||||
# only in `query`, or $from reaches the plugin uninterpolated.
|
||||
query: |-
|
||||
SELECT count() as errors
|
||||
FROM 'MEASURE'
|
||||
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
|
||||
AND blob1 = 'version_chain_error'
|
||||
AND blob2 = 'production-tldraw-multiplayer'
|
||||
rawQuery: true
|
||||
refId: A
|
||||
round: 0s
|
||||
skip_comments: true
|
||||
step: ''
|
||||
relativeTimeRange:
|
||||
from: 1h0m0s
|
||||
to: 0s
|
||||
C:
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 0
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- C
|
||||
reducer:
|
||||
params: []
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
queryType: expression
|
||||
source: true
|
||||
for: 10m0s
|
||||
labels:
|
||||
service: dotcom
|
||||
severity: warning
|
||||
noDataState: Ok
|
||||
notificationSettings:
|
||||
receiver: Discord
|
||||
type: SimplifiedRouting
|
||||
title: Version chain write errors
|
||||
trigger:
|
||||
interval: 10m
|
||||
@@ -0,0 +1,116 @@
|
||||
apiVersion: rules.alerting.grafana.app/v0alpha1
|
||||
kind: AlertRule
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: anthropic
|
||||
grafana.com/provenance: ''
|
||||
grafana.com/updateTimestamp: '2026-03-20T11:22:55Z'
|
||||
grafana.com/updatedBy: bf1wptf89bpq8d
|
||||
labels:
|
||||
grafana.app/folder: anthropic
|
||||
grafana.com/group: anthropic
|
||||
grafana.com/group-index: '0'
|
||||
name: e75d2f6c-44cf-54bf-8a74-ced2e045fcb6
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
annotations:
|
||||
description: 'The last completed day cost ${{ $value | humanize }}, more than
|
||||
1.5x the day before. The cost metric updates once a day at about 03:00 UTC,
|
||||
so this describes yesterday.'
|
||||
summary: Anthropic API daily cost spike detected.
|
||||
execErrState: Ok
|
||||
expressions:
|
||||
prometheus_math:
|
||||
model:
|
||||
datasource:
|
||||
IsPrunable: false
|
||||
access: ''
|
||||
apiVersion: ''
|
||||
basicAuth: false
|
||||
basicAuthUser: ''
|
||||
created: '0001-01-01T00:00:00Z'
|
||||
database: ''
|
||||
id: -100
|
||||
isDefault: false
|
||||
jsonData: {}
|
||||
name: __expr__
|
||||
readOnly: false
|
||||
secureJsonData: {}
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
updated: '0001-01-01T00:00:00Z'
|
||||
url: ''
|
||||
user: ''
|
||||
withCredentials: false
|
||||
expression: is_number($query) || is_nan($query) || is_inf($query)
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: prometheus_math
|
||||
type: math
|
||||
queryType: math
|
||||
query:
|
||||
datasourceUID: grafanacloud-prom
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: grafanacloud-prom
|
||||
expr: |
|
||||
(
|
||||
sum(increase(gen_ai_cost{job=~"integrations/anthropic/.*"}[1d])) / 100
|
||||
>
|
||||
1.5 * sum(increase(gen_ai_cost{job=~"integrations/anthropic/.*"}[1d] offset 1d)) / 100
|
||||
)
|
||||
and
|
||||
sum(increase(gen_ai_cost{job=~"integrations/anthropic/.*"}[1d])) / 100 > 50
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
range: false
|
||||
refId: query
|
||||
queryType: prometheus
|
||||
relativeTimeRange:
|
||||
from: 11m0s
|
||||
to: 1m0s
|
||||
threshold:
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 0
|
||||
type: gt
|
||||
datasource:
|
||||
IsPrunable: false
|
||||
access: ''
|
||||
apiVersion: ''
|
||||
basicAuth: false
|
||||
basicAuthUser: ''
|
||||
created: '0001-01-01T00:00:00Z'
|
||||
database: ''
|
||||
id: -100
|
||||
isDefault: false
|
||||
jsonData: {}
|
||||
name: __expr__
|
||||
readOnly: false
|
||||
secureJsonData: {}
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
updated: '0001-01-01T00:00:00Z'
|
||||
url: ''
|
||||
user: ''
|
||||
withCredentials: false
|
||||
expression: prometheus_math
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: threshold
|
||||
type: threshold
|
||||
queryType: threshold
|
||||
source: true
|
||||
for: 5m0s
|
||||
labels:
|
||||
__converted_prometheus_rule__: 'true'
|
||||
severity: warning
|
||||
missingSeriesEvalsToResolve: 10
|
||||
noDataState: Ok
|
||||
title: AnthropicDailyCostSpike
|
||||
trigger:
|
||||
interval: 1m
|
||||
@@ -0,0 +1,110 @@
|
||||
apiVersion: rules.alerting.grafana.app/v0alpha1
|
||||
kind: AlertRule
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: anthropic
|
||||
grafana.com/provenance: ''
|
||||
grafana.com/updateTimestamp: '2026-03-20T11:22:55Z'
|
||||
grafana.com/updatedBy: bf1wptf89bpq8d
|
||||
labels:
|
||||
grafana.app/folder: anthropic
|
||||
grafana.com/group: anthropic
|
||||
grafana.com/group-index: '2'
|
||||
name: eec2ad9e-30b9-5c8f-802f-53e26ab8fdc9
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
annotations:
|
||||
description: 'The last completed day cost ${{ $value | humanize }}, above the
|
||||
$1000 threshold. The cost metric updates once a day at about 03:00 UTC, so
|
||||
this describes yesterday.'
|
||||
summary: Anthropic API cost threshold exceeded.
|
||||
execErrState: Ok
|
||||
expressions:
|
||||
prometheus_math:
|
||||
model:
|
||||
datasource:
|
||||
IsPrunable: false
|
||||
access: ''
|
||||
apiVersion: ''
|
||||
basicAuth: false
|
||||
basicAuthUser: ''
|
||||
created: '0001-01-01T00:00:00Z'
|
||||
database: ''
|
||||
id: -100
|
||||
isDefault: false
|
||||
jsonData: {}
|
||||
name: __expr__
|
||||
readOnly: false
|
||||
secureJsonData: {}
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
updated: '0001-01-01T00:00:00Z'
|
||||
url: ''
|
||||
user: ''
|
||||
withCredentials: false
|
||||
expression: is_number($query) || is_nan($query) || is_inf($query)
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: prometheus_math
|
||||
type: math
|
||||
queryType: math
|
||||
query:
|
||||
datasourceUID: grafanacloud-prom
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: grafanacloud-prom
|
||||
expr: |
|
||||
sum(increase(gen_ai_cost{job=~"integrations/anthropic/.*"}[1d])) / 100 > 1000
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
range: false
|
||||
refId: query
|
||||
queryType: prometheus
|
||||
relativeTimeRange:
|
||||
from: 11m0s
|
||||
to: 1m0s
|
||||
threshold:
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 0
|
||||
type: gt
|
||||
datasource:
|
||||
IsPrunable: false
|
||||
access: ''
|
||||
apiVersion: ''
|
||||
basicAuth: false
|
||||
basicAuthUser: ''
|
||||
created: '0001-01-01T00:00:00Z'
|
||||
database: ''
|
||||
id: -100
|
||||
isDefault: false
|
||||
jsonData: {}
|
||||
name: __expr__
|
||||
readOnly: false
|
||||
secureJsonData: {}
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
updated: '0001-01-01T00:00:00Z'
|
||||
url: ''
|
||||
user: ''
|
||||
withCredentials: false
|
||||
expression: prometheus_math
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: threshold
|
||||
type: threshold
|
||||
queryType: threshold
|
||||
source: true
|
||||
for: 15m0s
|
||||
labels:
|
||||
__converted_prometheus_rule__: 'true'
|
||||
severity: critical
|
||||
missingSeriesEvalsToResolve: 10
|
||||
noDataState: Ok
|
||||
title: AnthropicHighCostThreshold
|
||||
trigger:
|
||||
interval: 1m
|
||||
@@ -0,0 +1,66 @@
|
||||
apiVersion: rules.alerting.grafana.app/v0alpha1
|
||||
kind: AlertRule
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: f6hsw6
|
||||
grafana.com/provenance: ''
|
||||
grafana.com/updateTimestamp: '2026-04-21T09:07:11Z'
|
||||
grafana.com/updatedBy: ae5t47muqbegwf
|
||||
labels:
|
||||
grafana.app/folder: f6hsw6
|
||||
grafana.com/group: zero-alerts-fast
|
||||
grafana.com/group-index: '1'
|
||||
name: efjn4hgdlhc00f
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
execErrState: Error
|
||||
expressions:
|
||||
A:
|
||||
datasourceUID: grafanacloud-prom
|
||||
model:
|
||||
editorMode: code
|
||||
expr: zero_replication_total_lag_millisecond{deployment_environment="production",
|
||||
service_name="zero-rm"}
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
legendFormat: __auto
|
||||
maxDataPoints: 43200
|
||||
range: false
|
||||
refId: A
|
||||
relativeTimeRange:
|
||||
from: 10m0s
|
||||
to: 0s
|
||||
C:
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 2000
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- C
|
||||
reducer:
|
||||
params: []
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
queryType: expression
|
||||
source: true
|
||||
for: 5m0s
|
||||
labels:
|
||||
service: zero
|
||||
severity: critical
|
||||
noDataState: NoData
|
||||
title: Zero replication lag high
|
||||
trigger:
|
||||
interval: 5m
|
||||
@@ -0,0 +1,69 @@
|
||||
apiVersion: rules.alerting.grafana.app/v0alpha1
|
||||
kind: AlertRule
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: f6hsw6
|
||||
grafana.com/provenance: ''
|
||||
grafana.com/updateTimestamp: '2026-04-28T08:48:46Z'
|
||||
grafana.com/updatedBy: ae5t47muqbegwf
|
||||
labels:
|
||||
grafana.app/folder: f6hsw6
|
||||
grafana.com/group: zero-fly-alerts
|
||||
grafana.com/group-index: '4'
|
||||
name: efjnbq9h4iha8a
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
annotations:
|
||||
summary: Zero container OOM kill detected
|
||||
execErrState: Ok
|
||||
expressions:
|
||||
A:
|
||||
datasourceUID: afj5re4yk9og0e
|
||||
model:
|
||||
datasource:
|
||||
type: prometheus
|
||||
uid: afj5re4yk9og0e
|
||||
editorMode: code
|
||||
expr: fly_instance_exit_oom{app=~"production-zero-rm|production-zero-vs"}
|
||||
instant: true
|
||||
intervalMs: 1000
|
||||
legendFormat: __auto
|
||||
maxDataPoints: 43200
|
||||
range: false
|
||||
refId: A
|
||||
relativeTimeRange:
|
||||
from: 10m0s
|
||||
to: 0s
|
||||
C:
|
||||
model:
|
||||
conditions:
|
||||
- evaluator:
|
||||
params:
|
||||
- 0
|
||||
type: gt
|
||||
operator:
|
||||
type: and
|
||||
query:
|
||||
params:
|
||||
- C
|
||||
reducer:
|
||||
params: []
|
||||
type: last
|
||||
type: query
|
||||
datasource:
|
||||
type: __expr__
|
||||
uid: __expr__
|
||||
expression: A
|
||||
intervalMs: 1000
|
||||
maxDataPoints: 43200
|
||||
refId: C
|
||||
type: threshold
|
||||
queryType: expression
|
||||
source: true
|
||||
labels:
|
||||
service: zero
|
||||
severity: critical
|
||||
noDataState: Ok
|
||||
title: Flyio zero container oom kill
|
||||
trigger:
|
||||
interval: 1m
|
||||
@@ -0,0 +1,254 @@
|
||||
apiVersion: dashboard.grafana.app/v0alpha1
|
||||
kind: Dashboard
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: dotcom
|
||||
grafana.app/saved-from-ui: Grafana Cloud
|
||||
labels: {}
|
||||
name: adgumkhou3chsd
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
annotations:
|
||||
list:
|
||||
- builtIn: 1
|
||||
datasource:
|
||||
type: grafana
|
||||
uid: -- Grafana --
|
||||
enable: true
|
||||
hide: true
|
||||
iconColor: rgba(0, 211, 255, 1)
|
||||
name: Annotations & Alerts
|
||||
type: dashboard
|
||||
editable: true
|
||||
fiscalYearStartMonth: 0
|
||||
graphTooltip: 0
|
||||
liveNow: false
|
||||
panels:
|
||||
- datasource:
|
||||
type: vertamedia-clickhouse-datasource
|
||||
uid: bdgu7tk03irr4e
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: palette-classic
|
||||
custom:
|
||||
axisBorderShow: false
|
||||
axisCenteredZero: false
|
||||
axisColorMode: text
|
||||
axisLabel: ''
|
||||
axisPlacement: auto
|
||||
barAlignment: 0
|
||||
barWidthFactor: 0.6
|
||||
drawStyle: line
|
||||
fillOpacity: 0
|
||||
gradientMode: none
|
||||
hideFrom:
|
||||
legend: false
|
||||
tooltip: false
|
||||
viz: false
|
||||
insertNulls: false
|
||||
lineInterpolation: linear
|
||||
lineWidth: 1
|
||||
pointSize: 5
|
||||
scaleDistribution:
|
||||
type: linear
|
||||
showPoints: auto
|
||||
showValues: false
|
||||
spanNulls: false
|
||||
stacking:
|
||||
group: A
|
||||
mode: none
|
||||
thresholdsStyle:
|
||||
mode: 'off'
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: 0
|
||||
- color: red
|
||||
value: 80
|
||||
gridPos:
|
||||
h: 21
|
||||
w: 24
|
||||
x: 0
|
||||
y: 0
|
||||
id: 1
|
||||
options:
|
||||
annotations:
|
||||
clustering: -1
|
||||
multiLane: false
|
||||
legend:
|
||||
calcs: []
|
||||
displayMode: list
|
||||
placement: bottom
|
||||
showLegend: true
|
||||
tooltip:
|
||||
hideZeros: false
|
||||
maxHeight: 600
|
||||
mode: single
|
||||
sort: none
|
||||
pluginVersion: 13.1.0-24556818486
|
||||
targets:
|
||||
- datasource:
|
||||
type: vertamedia-clickhouse-datasource
|
||||
uid: bdgu7tk03irr4e
|
||||
dateTimeType: DATETIME
|
||||
editorMode: builder
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formatAs: time_series
|
||||
formattedQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval' SECOND)\
|
||||
\ as date, blob1 as event, count() as total\n\tFROM 'MEASURE'\n WHERE NOT\
|
||||
\ endsWith(event, 'tldraw-multiplayer')\n\tAND timestamp >= toDateTime($from)\
|
||||
\ AND timestamp <= toDateTime($to)\n\tGROUP BY date, event\n\tORDER BY date\
|
||||
\ ASC"
|
||||
intervalFactor: 1
|
||||
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval' SECOND) as\
|
||||
\ date, blob1 as event, count() as total\n\tFROM 'MEASURE'\n\tWHERE timestamp\
|
||||
\ >= toDateTime($from) AND timestamp <= toDateTime($to)\n\tAND blob2 = '${environment}-tldraw-multiplayer'\n\
|
||||
\tGROUP BY date, event\n\tORDER BY date ASC"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120' SECOND) as date,\
|
||||
\ blob1 as event, count() as total\n\tFROM 'MEASURE'\n\tWHERE timestamp >=\
|
||||
\ toDateTime(1733213050) AND timestamp <= toDateTime(1733385850)\n\tAND blob2\
|
||||
\ = 'pr-5028-tldraw-multiplayer'\n\tGROUP BY date, event\n\tORDER BY date ASC"
|
||||
refId: A
|
||||
round: 0s
|
||||
showFormattedSQL: true
|
||||
showHelp: true
|
||||
skip_comments: true
|
||||
step: ''
|
||||
title: Room events
|
||||
type: timeseries
|
||||
- datasource:
|
||||
type: vertamedia-clickhouse-datasource
|
||||
uid: bdgu7tk03irr4e
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: palette-classic
|
||||
custom:
|
||||
axisBorderShow: false
|
||||
axisCenteredZero: false
|
||||
axisColorMode: text
|
||||
axisLabel: ''
|
||||
axisPlacement: right
|
||||
barAlignment: 0
|
||||
barWidthFactor: 0.6
|
||||
drawStyle: line
|
||||
fillOpacity: 0
|
||||
gradientMode: none
|
||||
hideFrom:
|
||||
legend: false
|
||||
tooltip: false
|
||||
viz: false
|
||||
insertNulls: 3600000
|
||||
lineInterpolation: stepAfter
|
||||
lineStyle:
|
||||
fill: solid
|
||||
lineWidth: 1
|
||||
pointSize: 6
|
||||
scaleDistribution:
|
||||
type: linear
|
||||
showPoints: auto
|
||||
showValues: false
|
||||
spanNulls: false
|
||||
stacking:
|
||||
group: A
|
||||
mode: none
|
||||
thresholdsStyle:
|
||||
mode: 'off'
|
||||
min: 0
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: 0
|
||||
- color: red
|
||||
value: 80
|
||||
gridPos:
|
||||
h: 8
|
||||
w: 12
|
||||
x: 0
|
||||
y: 21
|
||||
id: 13
|
||||
interval: 5m
|
||||
options:
|
||||
annotations:
|
||||
clustering: -1
|
||||
multiLane: false
|
||||
legend:
|
||||
calcs: []
|
||||
displayMode: table
|
||||
placement: bottom
|
||||
showLegend: false
|
||||
tooltip:
|
||||
hideZeros: false
|
||||
mode: single
|
||||
sort: none
|
||||
pluginVersion: 13.1.0-24556818486
|
||||
targets:
|
||||
- datasource:
|
||||
type: vertamedia-clickhouse-datasource
|
||||
uid: bdgu7tk03irr4e
|
||||
dateTimeType: DATETIME
|
||||
editorMode: builder
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formatAs: time_series
|
||||
formattedQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval' SECOND)\
|
||||
\ as date, blob1 as name, max(double1) as avg_attempts\n\tFROM 'MEASURE'\n\
|
||||
\tWHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)\n \
|
||||
\ AND name = 'persist_success'\n\tGROUP BY date, name\n\tORDER BY date ASC"
|
||||
intervalFactor: 1
|
||||
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval' SECOND) as\
|
||||
\ date, blob1 as name, max(double1) as avg_attempts\n\tFROM 'MEASURE'\n\t\
|
||||
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)\n \
|
||||
\ AND name = 'persist_success'\n\tGROUP BY date, name\n\tORDER BY date ASC"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '300' SECOND) as date,\
|
||||
\ blob1 as name, max(double1) as avg_attempts\n\tFROM 'MEASURE'\n\tWHERE timestamp\
|
||||
\ >= toDateTime(1762169567) AND timestamp <= toDateTime(1762180367)\n AND\
|
||||
\ name = 'persist_success'\n\tGROUP BY date, name\n\tORDER BY date ASC"
|
||||
refId: A
|
||||
round: 0s
|
||||
showFormattedSQL: false
|
||||
showHelp: false
|
||||
skip_comments: true
|
||||
step: ''
|
||||
title: max attempts before persistence success
|
||||
type: timeseries
|
||||
preload: false
|
||||
refresh: 5s
|
||||
schemaVersion: 42
|
||||
templating:
|
||||
list:
|
||||
- current:
|
||||
text: production
|
||||
value: production
|
||||
description: Environment to filter by. Use production, staging, or your pr number
|
||||
(pr-42).
|
||||
hide: 0
|
||||
label: 'Environment: production | staging | pr-42'
|
||||
name: environment
|
||||
query: production
|
||||
skipUrlSync: false
|
||||
type: textbox
|
||||
time:
|
||||
from: now-3h
|
||||
to: now
|
||||
timepicker:
|
||||
refresh_intervals:
|
||||
- 5s
|
||||
- 10s
|
||||
- 30s
|
||||
- 1m
|
||||
- 5m
|
||||
- 15m
|
||||
- 30m
|
||||
- 1h
|
||||
- 2h
|
||||
- 1d
|
||||
timezone: browser
|
||||
title: Dotcom events V1
|
||||
tags:
|
||||
- provisioned
|
||||
- repo:tldraw/tldraw
|
||||
@@ -0,0 +1,195 @@
|
||||
apiVersion: dashboard.grafana.app/v0alpha1
|
||||
kind: Dashboard
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: anthropic
|
||||
grafana.app/message: creating dashboard from the Cloud Connections plugin
|
||||
labels: {}
|
||||
name: anthropic-integration-scrape-overview
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
editable: false
|
||||
fiscalYearStartMonth: 0
|
||||
graphTooltip: 0
|
||||
id: null
|
||||
links:
|
||||
- asDropdown: false
|
||||
icon: external link
|
||||
includeVars: true
|
||||
keepTime: true
|
||||
tags:
|
||||
- anthropic-integration
|
||||
targetBlank: true
|
||||
title: Dashboards
|
||||
type: dashboards
|
||||
liveNow: false
|
||||
panels:
|
||||
- gridPos:
|
||||
h: 12
|
||||
w: 7
|
||||
x: 0
|
||||
y: 0
|
||||
id: 1
|
||||
options:
|
||||
code:
|
||||
language: plaintext
|
||||
showLineNumbers: false
|
||||
showMiniMap: false
|
||||
content: <center><img style="width:auto;max-height:320px;" src="https://storage.googleapis.com/grafanalabs-integration-logos/anthropic-logo-dark.svg"></center>
|
||||
mode: markdown
|
||||
pluginVersion: 9.4.3
|
||||
title: Logo
|
||||
type: text
|
||||
- datasource:
|
||||
type: prometheus
|
||||
uid: $datasource
|
||||
description: Shows the number of samples received by metric name received for
|
||||
this integration.
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: palette-classic
|
||||
custom:
|
||||
axisCenteredZero: false
|
||||
axisColorMode: text
|
||||
axisLabel: ''
|
||||
axisPlacement: auto
|
||||
barAlignment: 0
|
||||
drawStyle: line
|
||||
fillOpacity: 0
|
||||
gradientMode: none
|
||||
hideFrom:
|
||||
legend: false
|
||||
tooltip: false
|
||||
viz: false
|
||||
lineInterpolation: linear
|
||||
lineStyle:
|
||||
fill: solid
|
||||
lineWidth: 1
|
||||
pointSize: 5
|
||||
scaleDistribution:
|
||||
type: linear
|
||||
showPoints: auto
|
||||
spanNulls: false
|
||||
stacking:
|
||||
group: A
|
||||
mode: none
|
||||
thresholdsStyle:
|
||||
mode: 'off'
|
||||
mappings: []
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: null
|
||||
- color: red
|
||||
value: 80
|
||||
unit: pps
|
||||
overrides: []
|
||||
gridPos:
|
||||
h: 12
|
||||
w: 17
|
||||
x: 7
|
||||
y: 0
|
||||
id: 7
|
||||
options:
|
||||
legend:
|
||||
calcs: []
|
||||
displayMode: list
|
||||
placement: bottom
|
||||
showLegend: true
|
||||
tooltip:
|
||||
mode: single
|
||||
sort: none
|
||||
targets:
|
||||
- datasource:
|
||||
type: prometheus
|
||||
uid: $datasource
|
||||
editorMode: code
|
||||
expr: count by (__name__) ({__name__=~".+", job=~"integrations/anthropic/.+",
|
||||
scrape_job=~"$scrape_job"})
|
||||
legendFormat: __auto
|
||||
range: true
|
||||
refId: A
|
||||
title: Samples Received
|
||||
type: timeseries
|
||||
refresh: 30s
|
||||
revision: 1
|
||||
schemaVersion: 38
|
||||
style: dark
|
||||
tags:
|
||||
- anthropic-integration
|
||||
- provisioned
|
||||
- repo:tldraw/tldraw
|
||||
templating:
|
||||
list:
|
||||
- current:
|
||||
selected: false
|
||||
text: default
|
||||
value: default
|
||||
hide: 0
|
||||
includeAll: false
|
||||
label: Data source
|
||||
multi: false
|
||||
name: datasource
|
||||
options: []
|
||||
query: prometheus
|
||||
queryValue: ''
|
||||
refresh: 1
|
||||
regex: (?!grafanacloud-usage|grafanacloud-ml-metrics).+
|
||||
skipUrlSync: false
|
||||
type: datasource
|
||||
- allValue: .+
|
||||
current:
|
||||
text: All
|
||||
value:
|
||||
- $__all
|
||||
datasource: $datasource
|
||||
definition: label_values({__name__=~".+", job=~"integrations/anthropic/.+"},
|
||||
scrape_job)
|
||||
hide: 0
|
||||
includeAll: true
|
||||
label: scrape_job
|
||||
multi: true
|
||||
name: scrape_job
|
||||
options: []
|
||||
query: label_values({__name__=~".+", job=~"integrations/anthropic/.+"}, scrape_job)
|
||||
refresh: 2
|
||||
regex: ''
|
||||
skipUrlSync: false
|
||||
sort: 0
|
||||
tagValuesQuery: ''
|
||||
tags: []
|
||||
tagsQuery: ''
|
||||
type: query
|
||||
useTags: false
|
||||
- allValue: integrations/anthropic/.+
|
||||
current:
|
||||
text: All
|
||||
value:
|
||||
- $__all
|
||||
datasource: $datasource
|
||||
definition: label_values({job=~"integrations/anthropic/.+"}, job)
|
||||
hide: 0
|
||||
includeAll: true
|
||||
label: job
|
||||
multi: true
|
||||
name: job
|
||||
options: []
|
||||
query: label_values({job=~"integrations/anthropic/.+"}, job)
|
||||
refresh: 2
|
||||
regex: ''
|
||||
skipUrlSync: false
|
||||
sort: 0
|
||||
tagValuesQuery: ''
|
||||
tags: []
|
||||
tagsQuery: ''
|
||||
type: query
|
||||
useTags: false
|
||||
time:
|
||||
from: now-30m
|
||||
to: now
|
||||
timepicker: {}
|
||||
timezone: ''
|
||||
title: Anthropic scrape overview
|
||||
weekStart: ''
|
||||
@@ -0,0 +1,375 @@
|
||||
apiVersion: dashboard.grafana.app/v0alpha1
|
||||
kind: Dashboard
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: dotcom
|
||||
labels: {}
|
||||
name: edrigjn435v5sf
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
annotations:
|
||||
list:
|
||||
- builtIn: 1
|
||||
datasource:
|
||||
type: grafana
|
||||
uid: -- Grafana --
|
||||
enable: true
|
||||
hide: true
|
||||
iconColor: rgba(0, 211, 255, 1)
|
||||
name: Annotations & Alerts
|
||||
type: dashboard
|
||||
editable: true
|
||||
fiscalYearStartMonth: 0
|
||||
graphTooltip: 0
|
||||
links: []
|
||||
panels:
|
||||
- datasource:
|
||||
type: vertamedia-clickhouse-datasource
|
||||
uid: bdgu7tk03irr4e
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: thresholds
|
||||
mappings: []
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: null
|
||||
- color: red
|
||||
value: 80
|
||||
overrides: []
|
||||
gridPos:
|
||||
h: 8
|
||||
w: 12
|
||||
x: 0
|
||||
y: 0
|
||||
id: 3
|
||||
options:
|
||||
minVizHeight: 75
|
||||
minVizWidth: 75
|
||||
orientation: auto
|
||||
reduceOptions:
|
||||
calcs:
|
||||
- sum
|
||||
fields: ''
|
||||
values: false
|
||||
showThresholdLabels: false
|
||||
showThresholdMarkers: true
|
||||
sizing: auto
|
||||
pluginVersion: 11.3.0-77222
|
||||
targets:
|
||||
- datasource:
|
||||
type: vertamedia-clickhouse-datasource
|
||||
uid: bdgu7tk03irr4e
|
||||
dateTimeType: DATETIME
|
||||
editorMode: builder
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formatAs: time_series
|
||||
formattedQuery: "SELECT count(*)\n\tFROM 'BEMO_ANALYICS'\n WHERE blob1 =\
|
||||
\ 'connect'\n\tAND timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)\n\
|
||||
\tGROUP BY date\n\tORDER BY date ASC"
|
||||
intervalFactor: 1
|
||||
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval' MINUTE) as\
|
||||
\ date, count() as numConnects\n\tFROM 'BEMO_ANALYTICS'\n WHERE blob1 =\
|
||||
\ 'connect'\n\tAND timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)\n\
|
||||
\tGROUP BY date\n\tORDER BY date ASC"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '1800' MINUTE) as date,\
|
||||
\ count() as numConnects\n\tFROM 'BEMO_ANALYTICS'\n WHERE blob1 = 'connect'\n\
|
||||
\tAND timestamp >= toDateTime(1722620600) AND timestamp <= toDateTime(1725212600)\n\
|
||||
\tGROUP BY date\n\tORDER BY date ASC"
|
||||
refId: A
|
||||
round: 0s
|
||||
showFormattedSQL: false
|
||||
showHelp: false
|
||||
skip_comments: true
|
||||
step: ''
|
||||
title: Num Connects
|
||||
type: gauge
|
||||
- datasource:
|
||||
type: vertamedia-clickhouse-datasource
|
||||
uid: bdgu7tk03irr4e
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: palette-classic
|
||||
custom:
|
||||
axisBorderShow: false
|
||||
axisCenteredZero: false
|
||||
axisColorMode: text
|
||||
axisLabel: ''
|
||||
axisPlacement: auto
|
||||
barAlignment: 0
|
||||
barWidthFactor: 0.6
|
||||
drawStyle: line
|
||||
fillOpacity: 0
|
||||
gradientMode: none
|
||||
hideFrom:
|
||||
legend: false
|
||||
tooltip: false
|
||||
viz: false
|
||||
insertNulls: false
|
||||
lineInterpolation: linear
|
||||
lineWidth: 1
|
||||
pointSize: 5
|
||||
scaleDistribution:
|
||||
type: linear
|
||||
showPoints: auto
|
||||
spanNulls: false
|
||||
stacking:
|
||||
group: A
|
||||
mode: none
|
||||
thresholdsStyle:
|
||||
mode: 'off'
|
||||
mappings: []
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: null
|
||||
- color: red
|
||||
value: 80
|
||||
overrides: []
|
||||
gridPos:
|
||||
h: 8
|
||||
w: 12
|
||||
x: 12
|
||||
y: 0
|
||||
id: 1
|
||||
options:
|
||||
legend:
|
||||
calcs: []
|
||||
displayMode: list
|
||||
placement: bottom
|
||||
showLegend: true
|
||||
tooltip:
|
||||
mode: single
|
||||
sort: none
|
||||
pluginVersion: 11.3.0-77222
|
||||
targets:
|
||||
- datasource:
|
||||
type: vertamedia-clickhouse-datasource
|
||||
uid: bdgu7tk03irr4e
|
||||
dateTimeType: DATETIME
|
||||
editorMode: builder
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formatAs: time_series
|
||||
formattedQuery: "SELECT count(*)\n\tFROM 'BEMO_ANALYICS'\n WHERE blob1 =\
|
||||
\ 'connect'\n\tAND timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)\n\
|
||||
\tGROUP BY date\n\tORDER BY date ASC"
|
||||
intervalFactor: 1
|
||||
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval' MINUTE) as\
|
||||
\ date, count() as numConnects\n\tFROM 'BEMO_ANALYTICS'\n WHERE blob1 =\
|
||||
\ 'connect'\n\tAND timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)\n\
|
||||
\tGROUP BY date\n\tORDER BY date ASC"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '3600' MINUTE) as date,\
|
||||
\ count() as numConnects\n\tFROM 'BEMO_ANALYTICS'\n WHERE blob1 = 'connect'\n\
|
||||
\tAND timestamp >= toDateTime(1720773373) AND timestamp <= toDateTime(1726292377)\n\
|
||||
\tGROUP BY date\n\tORDER BY date ASC"
|
||||
refId: A
|
||||
round: 0s
|
||||
showFormattedSQL: false
|
||||
showHelp: false
|
||||
skip_comments: true
|
||||
step: ''
|
||||
title: Num Connects
|
||||
type: timeseries
|
||||
- datasource:
|
||||
uid: bdgu7tk03irr4e
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: palette-classic
|
||||
custom:
|
||||
axisBorderShow: false
|
||||
axisCenteredZero: false
|
||||
axisColorMode: text
|
||||
axisLabel: ''
|
||||
axisPlacement: auto
|
||||
barAlignment: 0
|
||||
barWidthFactor: 0.6
|
||||
drawStyle: line
|
||||
fillOpacity: 0
|
||||
gradientMode: none
|
||||
hideFrom:
|
||||
legend: false
|
||||
tooltip: false
|
||||
viz: false
|
||||
insertNulls: false
|
||||
lineInterpolation: linear
|
||||
lineWidth: 1
|
||||
pointSize: 5
|
||||
scaleDistribution:
|
||||
type: linear
|
||||
showPoints: auto
|
||||
spanNulls: false
|
||||
stacking:
|
||||
group: A
|
||||
mode: none
|
||||
thresholdsStyle:
|
||||
mode: 'off'
|
||||
mappings: []
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: null
|
||||
- color: red
|
||||
value: 80
|
||||
overrides: []
|
||||
gridPos:
|
||||
h: 8
|
||||
w: 12
|
||||
x: 0
|
||||
y: 8
|
||||
id: 5
|
||||
options:
|
||||
legend:
|
||||
calcs: []
|
||||
displayMode: list
|
||||
placement: bottom
|
||||
showLegend: true
|
||||
tooltip:
|
||||
mode: single
|
||||
sort: none
|
||||
pluginVersion: 11.3.0-77222
|
||||
targets:
|
||||
- dateTimeType: DATETIME
|
||||
editorMode: builder
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formattedQuery: SELECT $timeSeries as t, count() FROM $table WHERE $timeFilter
|
||||
GROUP BY t ORDER BY t
|
||||
intervalFactor: 1
|
||||
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval' MINUTE) as\
|
||||
\ date, count() as numConnects,\n\tif(\n\t\tstartsWith(blob2, 'http://localhost')\
|
||||
\ or startsWith(blob2, 'http://127.0.0.1'),\n\t\t'localhost',\n\t\tif(\n\t\
|
||||
\t\tendsWith(blob2, '.csb.app'),\n\t\t\t'code sandbox',\n\t\t\tblob2\n\t\t\
|
||||
)\n\t) as host\n\tFROM 'BEMO_ANALYTICS'\n WHERE blob1 = 'connect'\n\tAND\
|
||||
\ timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)\n\t-- AND\
|
||||
\ NOT(startsWith(host, 'http://localhost')) AND NOT(startsWith(host, 'http://127.0.0.1'))\n\
|
||||
\tGROUP BY date, host\n ORDER BY date ASC"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '30' MINUTE) as date,\
|
||||
\ count() as numConnects,\n\tif(\n\t\tstartsWith(blob2, 'http://localhost')\
|
||||
\ or startsWith(blob2, 'http://127.0.0.1'),\n\t\t'localhost',\n\t\tif(\n\t\
|
||||
\t\tendsWith(blob2, '.csb.app'),\n\t\t\t'code sandbox',\n\t\t\tblob2\n\t\t\
|
||||
)\n\t) as host\n\tFROM 'BEMO_ANALYTICS'\n WHERE blob1 = 'connect'\n\tAND\
|
||||
\ timestamp >= toDateTime(1728620743) AND timestamp <= toDateTime(1728642343)\n\
|
||||
\t\n\tGROUP BY date, host\n ORDER BY date ASC"
|
||||
refId: A
|
||||
round: 0s
|
||||
skip_comments: true
|
||||
title: Num Connects
|
||||
type: timeseries
|
||||
- datasource:
|
||||
type: vertamedia-clickhouse-datasource
|
||||
uid: bdgu7tk03irr4e
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: palette-classic
|
||||
custom:
|
||||
axisBorderShow: false
|
||||
axisCenteredZero: false
|
||||
axisColorMode: text
|
||||
axisLabel: ''
|
||||
axisPlacement: auto
|
||||
barAlignment: 0
|
||||
barWidthFactor: 0.6
|
||||
drawStyle: line
|
||||
fillOpacity: 0
|
||||
gradientMode: none
|
||||
hideFrom:
|
||||
legend: false
|
||||
tooltip: false
|
||||
viz: false
|
||||
insertNulls: false
|
||||
lineInterpolation: smooth
|
||||
lineWidth: 1
|
||||
pointSize: 5
|
||||
scaleDistribution:
|
||||
type: linear
|
||||
showPoints: auto
|
||||
spanNulls: false
|
||||
stacking:
|
||||
group: A
|
||||
mode: none
|
||||
thresholdsStyle:
|
||||
mode: 'off'
|
||||
mappings: []
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: null
|
||||
- color: red
|
||||
value: 80
|
||||
overrides: []
|
||||
gridPos:
|
||||
h: 19
|
||||
w: 12
|
||||
x: 12
|
||||
y: 8
|
||||
id: 4
|
||||
options:
|
||||
legend:
|
||||
calcs: []
|
||||
displayMode: table
|
||||
placement: bottom
|
||||
showLegend: true
|
||||
tooltip:
|
||||
mode: single
|
||||
sort: none
|
||||
pluginVersion: 11.3.0-77222
|
||||
targets:
|
||||
- datasource:
|
||||
type: vertamedia-clickhouse-datasource
|
||||
uid: bdgu7tk03irr4e
|
||||
dateTimeType: DATETIME
|
||||
editorMode: builder
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formatAs: time_series
|
||||
formattedQuery: "SELECT count() as numConnects, blob2\n\tFROM 'BEMO_ANALYTICS'\n\
|
||||
\ WHERE blob1 = 'connect'\n\tAND timestamp >= toDateTime($from) AND timestamp\
|
||||
\ <= toDateTime($to)\n\tGROUP BY blob2\n ORDER BY numConnects ASC"
|
||||
intervalFactor: 1
|
||||
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval' MINUTE) as\
|
||||
\ date, count() as numConnects,\n\tif(\n\t\tstartsWith(blob2, 'http://localhost')\
|
||||
\ or startsWith(blob2, 'http://127.0.0.1'),\n\t\t'localhost',\n\t\tif(\n\t\
|
||||
\t\tendsWith(blob2, '.csb.app'),\n\t\t\t'code sandbox',\n\t\t\tblob2\n\t\t\
|
||||
)\n\t) as host\n\tFROM 'BEMO_ANALYTICS'\n WHERE blob1 = 'connect'\n\tAND\
|
||||
\ timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)\n\t-- AND\
|
||||
\ NOT(startsWith(host, 'http://localhost')) AND NOT(startsWith(host, 'http://127.0.0.1'))\n\
|
||||
\tGROUP BY date, host\n ORDER BY date ASC"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '3600' MINUTE) as date,\
|
||||
\ count() as numConnects,\n\tif(\n\t\tstartsWith(blob2, 'http://localhost')\
|
||||
\ or startsWith(blob2, 'http://127.0.0.1'),\n\t\t'localhost',\n\t\tif(\n\t\
|
||||
\t\tendsWith(blob2, '.csb.app'),\n\t\t\t'code sandbox',\n\t\t\tblob2\n\t\t\
|
||||
)\n\t) as host\n\tFROM 'BEMO_ANALYTICS'\n WHERE blob1 = 'connect'\n\tAND\
|
||||
\ timestamp >= toDateTime(1720773373) AND timestamp <= toDateTime(1726292377)\n\
|
||||
\t\n\tGROUP BY date, host\n ORDER BY date ASC"
|
||||
refId: A
|
||||
round: 0s
|
||||
showFormattedSQL: false
|
||||
showHelp: false
|
||||
skip_comments: true
|
||||
step: ''
|
||||
title: Panel Title
|
||||
type: timeseries
|
||||
preload: false
|
||||
schemaVersion: 40
|
||||
tags:
|
||||
- provisioned
|
||||
- repo:tldraw/tldraw
|
||||
templating:
|
||||
list: []
|
||||
time:
|
||||
from: now-6h
|
||||
to: now
|
||||
timepicker: {}
|
||||
timezone: browser
|
||||
title: Bemo analytics
|
||||
weekStart: ''
|
||||
@@ -0,0 +1,605 @@
|
||||
apiVersion: dashboard.grafana.app/v0alpha1
|
||||
kind: Dashboard
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/message: Add qtype split, env toggle, deploy annotations and restart
|
||||
panel
|
||||
grafana.app/folder: f6hsw6
|
||||
labels: {}
|
||||
name: slow-queries
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
annotations:
|
||||
list:
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
enable: true
|
||||
expr: '{service_name=~"$service", deployment_environment="$env"} |= "OpenTelemetry
|
||||
SDK started" | process_worker="reaper"'
|
||||
hide: false
|
||||
iconColor: red
|
||||
name: Deploys (machine restarts)
|
||||
target:
|
||||
expr: '{service_name=~"$service", deployment_environment="$env"} |= "OpenTelemetry
|
||||
SDK started" | process_worker="reaper"'
|
||||
queryType: range
|
||||
refId: Anno
|
||||
textFormat: '{{host_name}}'
|
||||
titleFormat: zero-cache restart
|
||||
editable: true
|
||||
graphTooltip: 1
|
||||
panels:
|
||||
- collapsed: false
|
||||
gridPos:
|
||||
h: 1
|
||||
w: 24
|
||||
x: 0
|
||||
y: 0
|
||||
id: 1
|
||||
panels: []
|
||||
title: Overview
|
||||
type: row
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
description: Total "Slow SQLite query" log lines from zero-cache in the selected
|
||||
time range.
|
||||
fieldConfig:
|
||||
defaults:
|
||||
unit: short
|
||||
overrides: []
|
||||
gridPos:
|
||||
h: 5
|
||||
w: 4
|
||||
x: 0
|
||||
y: 1
|
||||
id: 2
|
||||
options:
|
||||
colorMode: value
|
||||
graphMode: none
|
||||
reduceOptions:
|
||||
calcs:
|
||||
- lastNotNull
|
||||
targets:
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
editorMode: code
|
||||
expr: sum(count_over_time({service_name=~"$service", deployment_environment="$env"}
|
||||
|~ "Slow SQLite query" | type=~"$qtype" [$__range]))
|
||||
instant: true
|
||||
queryType: instant
|
||||
range: false
|
||||
refId: A
|
||||
title: Slow queries in range
|
||||
type: stat
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
description: Longest slow query seen in the selected time range.
|
||||
fieldConfig:
|
||||
defaults:
|
||||
unit: ms
|
||||
overrides: []
|
||||
gridPos:
|
||||
h: 5
|
||||
w: 4
|
||||
x: 4
|
||||
y: 1
|
||||
id: 3
|
||||
options:
|
||||
colorMode: value
|
||||
graphMode: none
|
||||
reduceOptions:
|
||||
calcs:
|
||||
- lastNotNull
|
||||
targets:
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
editorMode: code
|
||||
expr: max(max_over_time({service_name=~"$service", deployment_environment="$env"}
|
||||
|~ "Slow SQLite query" | type=~"$qtype" | regexp "Slow SQLite query (?P<duration_ms>[0-9.]+)"
|
||||
| unwrap duration_ms [$__range]))
|
||||
instant: true
|
||||
queryType: instant
|
||||
range: false
|
||||
refId: A
|
||||
title: Max duration in range
|
||||
type: stat
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
description: Slow SQLite query log lines per interval, by Zero service (zero-vs
|
||||
= view-syncer, zero-rm = replication-manager).
|
||||
fieldConfig:
|
||||
defaults:
|
||||
custom:
|
||||
fillOpacity: 8
|
||||
lineWidth: 1
|
||||
showPoints: never
|
||||
unit: short
|
||||
overrides: []
|
||||
gridPos:
|
||||
h: 10
|
||||
w: 8
|
||||
x: 8
|
||||
y: 1
|
||||
id: 4
|
||||
options:
|
||||
legend:
|
||||
displayMode: list
|
||||
placement: bottom
|
||||
showLegend: true
|
||||
tooltip:
|
||||
mode: multi
|
||||
sort: desc
|
||||
targets:
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
editorMode: code
|
||||
expr:
|
||||
sum by (service_name) (count_over_time({service_name=~"$service", deployment_environment="$env"}
|
||||
|~ "Slow SQLite query" | type=~"$qtype" [$__auto]))
|
||||
legendFormat: '{{service_name}}'
|
||||
queryType: range
|
||||
refId: A
|
||||
title: Slow query rate
|
||||
type: timeseries
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
description: Duration parsed from the log line, in ms. Only queries over zero-cache's
|
||||
slow threshold are logged, so these are percentiles of the slow tail, not of
|
||||
all queries.
|
||||
fieldConfig:
|
||||
defaults:
|
||||
custom:
|
||||
fillOpacity: 8
|
||||
lineWidth: 1
|
||||
showPoints: never
|
||||
unit: ms
|
||||
overrides: []
|
||||
gridPos:
|
||||
h: 10
|
||||
w: 8
|
||||
x: 16
|
||||
y: 1
|
||||
id: 5
|
||||
options:
|
||||
legend:
|
||||
displayMode: list
|
||||
placement: bottom
|
||||
showLegend: true
|
||||
tooltip:
|
||||
mode: multi
|
||||
sort: desc
|
||||
targets:
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
editorMode: code
|
||||
expr: quantile_over_time(0.5, {service_name=~"$service", deployment_environment="$env"}
|
||||
|~ "Slow SQLite query" | type=~"$qtype" | regexp "Slow SQLite query (?P<duration_ms>[0-9.]+)"
|
||||
| unwrap duration_ms [$__auto]) by ()
|
||||
legendFormat: p50
|
||||
queryType: range
|
||||
refId: A
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
editorMode: code
|
||||
expr: quantile_over_time(0.95, {service_name=~"$service", deployment_environment="$env"}
|
||||
|~ "Slow SQLite query" | type=~"$qtype" | regexp "Slow SQLite query (?P<duration_ms>[0-9.]+)"
|
||||
| unwrap duration_ms [$__auto]) by ()
|
||||
legendFormat: p95
|
||||
queryType: range
|
||||
refId: B
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
editorMode: code
|
||||
expr: quantile_over_time(0.99, {service_name=~"$service", deployment_environment="$env"}
|
||||
|~ "Slow SQLite query" | type=~"$qtype" | regexp "Slow SQLite query (?P<duration_ms>[0-9.]+)"
|
||||
| unwrap duration_ms [$__auto]) by ()
|
||||
legendFormat: p99
|
||||
queryType: range
|
||||
refId: C
|
||||
title: Slow query duration percentiles
|
||||
type: timeseries
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
description: Which Fly machines are producing slow queries.
|
||||
fieldConfig:
|
||||
defaults:
|
||||
custom:
|
||||
fillOpacity: 8
|
||||
lineWidth: 1
|
||||
showPoints: never
|
||||
unit: short
|
||||
overrides: []
|
||||
gridPos:
|
||||
h: 5
|
||||
w: 8
|
||||
x: 0
|
||||
y: 6
|
||||
id: 6
|
||||
options:
|
||||
legend:
|
||||
displayMode: list
|
||||
placement: bottom
|
||||
showLegend: true
|
||||
tooltip:
|
||||
mode: multi
|
||||
sort: desc
|
||||
targets:
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
editorMode: code
|
||||
expr:
|
||||
sum by (host_name) (count_over_time({service_name=~"$service", deployment_environment="$env"}
|
||||
|~ "Slow SQLite query" | type=~"$qtype" [$__auto]))
|
||||
legendFormat: '{{host_name}}'
|
||||
queryType: range
|
||||
refId: A
|
||||
title: Slow queries by worker host
|
||||
type: timeseries
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
description: Worker restarts (every zero-cache process logs one line per worker
|
||||
on boot; a fleet-wide burst = a deploy) and post-restart auth failures from
|
||||
the custom-query transform endpoint. Slow-query storms almost always line up
|
||||
with these.
|
||||
fieldConfig:
|
||||
defaults:
|
||||
custom:
|
||||
drawStyle: bars
|
||||
fillOpacity: 15
|
||||
lineWidth: 1
|
||||
showPoints: never
|
||||
unit: short
|
||||
overrides: []
|
||||
gridPos:
|
||||
h: 5
|
||||
w: 16
|
||||
x: 8
|
||||
y: 11
|
||||
id: 15
|
||||
options:
|
||||
legend:
|
||||
displayMode: list
|
||||
placement: bottom
|
||||
showLegend: true
|
||||
tooltip:
|
||||
mode: multi
|
||||
sort: desc
|
||||
targets:
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
editorMode: code
|
||||
expr: sum(count_over_time({service_name=~"$service", deployment_environment="$env"}
|
||||
|= "OpenTelemetry SDK started" [$__auto]))
|
||||
legendFormat: worker restarts
|
||||
queryType: range
|
||||
refId: A
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
editorMode: code
|
||||
expr: sum(count_over_time({service_name=~"$service", deployment_environment="$env"}
|
||||
|= "Connection auth validation failed" [$__auto]))
|
||||
legendFormat: auth 401s on reconnect
|
||||
queryType: range
|
||||
refId: B
|
||||
title: Deploys and reconnect storms
|
||||
type: timeseries
|
||||
- collapsed: false
|
||||
gridPos:
|
||||
h: 1
|
||||
w: 24
|
||||
x: 0
|
||||
y: 16
|
||||
id: 7
|
||||
panels: []
|
||||
title: By statement
|
||||
type: row
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
description: The sql field is attached as structured metadata on each slow query
|
||||
log line.
|
||||
fieldConfig:
|
||||
defaults:
|
||||
custom:
|
||||
align: auto
|
||||
filterable: false
|
||||
inspect: true
|
||||
unit: short
|
||||
overrides: []
|
||||
gridPos:
|
||||
h: 11
|
||||
w: 12
|
||||
x: 0
|
||||
y: 17
|
||||
id: 8
|
||||
options:
|
||||
showHeader: true
|
||||
sortBy:
|
||||
- desc: true
|
||||
displayName: slow queries
|
||||
targets:
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
editorMode: code
|
||||
expr:
|
||||
topk(15, sum by (sql) (count_over_time({service_name=~"$service", deployment_environment="$env"}
|
||||
|~ "Slow SQLite query" | type=~"$qtype" [$__range])))
|
||||
instant: true
|
||||
queryType: instant
|
||||
range: false
|
||||
refId: A
|
||||
title: Top statements by slow query count
|
||||
transformations:
|
||||
- id: organize
|
||||
options:
|
||||
excludeByName:
|
||||
Time: true
|
||||
renameByName:
|
||||
Value: slow queries
|
||||
'Value #A': slow queries
|
||||
type: table
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
description: p95 of parsed duration, grouped by statement.
|
||||
fieldConfig:
|
||||
defaults:
|
||||
custom:
|
||||
align: auto
|
||||
filterable: false
|
||||
inspect: true
|
||||
unit: ms
|
||||
overrides: []
|
||||
gridPos:
|
||||
h: 11
|
||||
w: 12
|
||||
x: 12
|
||||
y: 17
|
||||
id: 9
|
||||
options:
|
||||
showHeader: true
|
||||
sortBy:
|
||||
- desc: true
|
||||
displayName: p95 ms
|
||||
targets:
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
editorMode: code
|
||||
expr:
|
||||
topk(15, quantile_over_time(0.95, {service_name=~"$service", deployment_environment="$env"}
|
||||
|~ "Slow SQLite query" | type=~"$qtype" | regexp "Slow SQLite query (?P<duration_ms>[0-9.]+)"
|
||||
| unwrap duration_ms [$__range]) by (sql))
|
||||
instant: true
|
||||
queryType: instant
|
||||
range: false
|
||||
refId: A
|
||||
title: Top statements by p95 duration (ms)
|
||||
transformations:
|
||||
- id: organize
|
||||
options:
|
||||
excludeByName:
|
||||
Time: true
|
||||
renameByName:
|
||||
Value: p95 ms
|
||||
'Value #A': p95 ms
|
||||
type: table
|
||||
- collapsed: false
|
||||
gridPos:
|
||||
h: 1
|
||||
w: 24
|
||||
x: 0
|
||||
y: 28
|
||||
id: 10
|
||||
panels: []
|
||||
title: By table
|
||||
type: row
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
description: "Table name extracted from the sql structured-metadata field. (Per-client
|
||||
breakdowns are not possible here: more than 500 distinct clientGroupID values
|
||||
per query window exceeds Loki's series limit.)"
|
||||
fieldConfig:
|
||||
defaults:
|
||||
custom:
|
||||
fillOpacity: 8
|
||||
lineWidth: 1
|
||||
showPoints: never
|
||||
stacking:
|
||||
group: A
|
||||
mode: normal
|
||||
unit: short
|
||||
overrides: []
|
||||
gridPos:
|
||||
h: 9
|
||||
w: 12
|
||||
x: 0
|
||||
y: 29
|
||||
id: 11
|
||||
options:
|
||||
legend:
|
||||
displayMode: list
|
||||
placement: bottom
|
||||
showLegend: true
|
||||
tooltip:
|
||||
mode: multi
|
||||
sort: desc
|
||||
targets:
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
editorMode: code
|
||||
expr:
|
||||
sum by (tbl) (count_over_time({service_name=~"$service", deployment_environment="$env"}
|
||||
|~ "Slow SQLite query" | type=~"$qtype" | label_format tbl=`{{ regexReplaceAll
|
||||
"(?s).*FROM .([a-zA-Z0-9._]+).*" .sql "$1" }}` [$__auto]))
|
||||
legendFormat: '{{tbl}}'
|
||||
queryType: range
|
||||
refId: A
|
||||
title: Slow queries by table
|
||||
type: timeseries
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
description: zero-cache tags each statement event with class/method/type (e.g.
|
||||
Statement/iterate/total).
|
||||
fieldConfig:
|
||||
defaults:
|
||||
custom:
|
||||
fillOpacity: 8
|
||||
lineWidth: 1
|
||||
showPoints: never
|
||||
unit: short
|
||||
overrides: []
|
||||
gridPos:
|
||||
h: 9
|
||||
w: 12
|
||||
x: 12
|
||||
y: 29
|
||||
id: 12
|
||||
options:
|
||||
legend:
|
||||
displayMode: list
|
||||
placement: bottom
|
||||
showLegend: true
|
||||
tooltip:
|
||||
mode: multi
|
||||
sort: desc
|
||||
targets:
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
editorMode: code
|
||||
expr:
|
||||
sum by (method, type) (count_over_time({service_name=~"$service", deployment_environment="$env"}
|
||||
|~ "Slow SQLite query" | type=~"$qtype" [$__auto]))
|
||||
legendFormat: '{{method}}/{{type}}'
|
||||
queryType: range
|
||||
refId: A
|
||||
title: Slow queries by method / type
|
||||
type: timeseries
|
||||
- collapsed: false
|
||||
gridPos:
|
||||
h: 1
|
||||
w: 24
|
||||
x: 0
|
||||
y: 38
|
||||
id: 13
|
||||
panels: []
|
||||
title: Raw logs
|
||||
type: row
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
gridPos:
|
||||
h: 12
|
||||
w: 24
|
||||
x: 0
|
||||
y: 39
|
||||
id: 14
|
||||
options:
|
||||
dedupStrategy: none
|
||||
enableLogDetails: true
|
||||
showTime: true
|
||||
sortOrder: Descending
|
||||
wrapLogMessage: true
|
||||
targets:
|
||||
- datasource:
|
||||
type: loki
|
||||
uid: grafanacloud-logs
|
||||
editorMode: code
|
||||
expr: '{service_name=~"$service", deployment_environment="$env"} |~ "Slow SQLite
|
||||
query" | type=~"$qtype"'
|
||||
maxLines: 200
|
||||
queryType: range
|
||||
refId: A
|
||||
title: Recent slow query logs
|
||||
type: logs
|
||||
refresh: ''
|
||||
schemaVersion: 42
|
||||
tags:
|
||||
- zero
|
||||
- loki
|
||||
- slow-queries
|
||||
- provisioned
|
||||
- repo:tldraw/tldraw
|
||||
templating:
|
||||
list:
|
||||
- allValue: zero-.+
|
||||
current:
|
||||
text:
|
||||
- All
|
||||
value:
|
||||
- $__all
|
||||
hide: 0
|
||||
includeAll: true
|
||||
label: Zero service
|
||||
multi: true
|
||||
name: service
|
||||
options: []
|
||||
query: zero-vs,zero-rm
|
||||
type: custom
|
||||
- current:
|
||||
text: production
|
||||
value: production
|
||||
hide: 0
|
||||
includeAll: false
|
||||
label: Environment
|
||||
multi: false
|
||||
name: env
|
||||
options:
|
||||
- selected: true
|
||||
text: production
|
||||
value: production
|
||||
- selected: false
|
||||
text: staging
|
||||
value: staging
|
||||
query: production,staging
|
||||
type: custom
|
||||
- allValue: .*
|
||||
current:
|
||||
text: All
|
||||
value: $__all
|
||||
description: total = wall-clock across row iteration (includes syncer pipeline
|
||||
stalls); sqlite = time inside the SQLite engine. Filter to sqlite to see whether
|
||||
the database itself is slow.
|
||||
hide: 0
|
||||
includeAll: true
|
||||
label: Query type
|
||||
multi: false
|
||||
name: qtype
|
||||
options:
|
||||
- selected: true
|
||||
text: All
|
||||
value: $__all
|
||||
- selected: false
|
||||
text: total
|
||||
value: total
|
||||
- selected: false
|
||||
text: sqlite
|
||||
value: sqlite
|
||||
query: total,sqlite
|
||||
type: custom
|
||||
time:
|
||||
from: now-6h
|
||||
to: now
|
||||
timezone: browser
|
||||
title: Zero slow queries
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,819 @@
|
||||
apiVersion: dashboard.grafana.app/v2
|
||||
kind: Dashboard
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: dotcom
|
||||
grafana.app/saved-from-ui: Grafana Cloud
|
||||
labels: {}
|
||||
name: mix4slb
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
annotations:
|
||||
- kind: AnnotationQuery
|
||||
spec:
|
||||
builtIn: true
|
||||
enable: true
|
||||
hide: true
|
||||
iconColor: rgba(0, 211, 255, 1)
|
||||
name: Annotations & Alerts
|
||||
query:
|
||||
datasource:
|
||||
name: -- Grafana --
|
||||
group: grafana
|
||||
kind: DataQuery
|
||||
spec: {}
|
||||
version: v0
|
||||
cursorSync: 'Off'
|
||||
editable: true
|
||||
elements:
|
||||
panel-1:
|
||||
kind: Panel
|
||||
spec:
|
||||
data:
|
||||
kind: QueryGroup
|
||||
spec:
|
||||
queries:
|
||||
- kind: PanelQuery
|
||||
spec:
|
||||
hidden: false
|
||||
query:
|
||||
datasource:
|
||||
name: bdgu7tk03irr4e
|
||||
group: vertamedia-clickhouse-datasource
|
||||
kind: DataQuery
|
||||
spec:
|
||||
dateTimeType: DATETIME
|
||||
editorMode: builder
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formatAs: time_series
|
||||
formattedQuery: "SELECT toStartOfInterval(timestamp, INTERVAL\
|
||||
\ '$interval' SECOND) as date, quantileWeighted(0.5)(double1,\
|
||||
\ _sample_interval) as p50_ms, quantileWeighted(0.95)(double1,\
|
||||
\ _sample_interval) as p95_ms, quantileWeighted(0.99)(double1,\
|
||||
\ _sample_interval) as p99_ms\n\tFROM 'MEASURE'\n\tWHERE timestamp\
|
||||
\ >= toDateTime($from) AND timestamp <= toDateTime($to)\n\t\
|
||||
AND blob2 = '${environment}-tldraw-multiplayer'\n\tAND blob1\
|
||||
\ = 'outbox_effect'\n\tGROUP BY date\n\tORDER BY date ASC"
|
||||
intervalFactor: 1
|
||||
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
|
||||
\ SECOND) as date, quantileWeighted(0.5)(double1, _sample_interval)\
|
||||
\ as p50_ms, quantileWeighted(0.95)(double1, _sample_interval)\
|
||||
\ as p95_ms, quantileWeighted(0.99)(double1, _sample_interval)\
|
||||
\ as p99_ms\n\tFROM 'MEASURE'\n\tWHERE timestamp >= toDateTime($from)\
|
||||
\ AND timestamp <= toDateTime($to)\n\tAND blob2 = '${environment}-tldraw-multiplayer'\n\
|
||||
\tAND blob1 = 'outbox_effect'\n\tGROUP BY date\n\tORDER BY date\
|
||||
\ ASC"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
|
||||
\ SECOND) as date, quantileWeighted(0.5)(double1, _sample_interval)\
|
||||
\ as p50_ms, quantileWeighted(0.95)(double1, _sample_interval)\
|
||||
\ as p95_ms, quantileWeighted(0.99)(double1, _sample_interval)\
|
||||
\ as p99_ms\n\tFROM 'MEASURE'\n\tWHERE timestamp >= toDateTime(1762170000)\
|
||||
\ AND timestamp <= toDateTime(1762180000)\n\tAND blob2 = 'production-tldraw-multiplayer'\n\
|
||||
\tAND blob1 = 'outbox_effect'\n\tGROUP BY date\n\tORDER BY date\
|
||||
\ ASC"
|
||||
round: 0s
|
||||
showFormattedSQL: true
|
||||
skip_comments: true
|
||||
step: ''
|
||||
version: v0
|
||||
refId: A
|
||||
queryOptions: {}
|
||||
transformations: []
|
||||
description: 'End-to-end: time from a file change committing to its effect
|
||||
completing (room notification / publish). double1 = ageMs.'
|
||||
id: 1
|
||||
links: []
|
||||
title: Outbox effect latency (p50 / p95 / p99)
|
||||
vizConfig:
|
||||
group: timeseries
|
||||
kind: VizConfig
|
||||
spec:
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: palette-classic
|
||||
custom:
|
||||
axisBorderShow: false
|
||||
axisCenteredZero: false
|
||||
axisColorMode: text
|
||||
axisLabel: ''
|
||||
axisPlacement: right
|
||||
barAlignment: 0
|
||||
barWidthFactor: 0.6
|
||||
drawStyle: line
|
||||
fillOpacity: 0
|
||||
gradientMode: none
|
||||
hideFrom:
|
||||
legend: false
|
||||
tooltip: false
|
||||
viz: false
|
||||
insertNulls: false
|
||||
lineInterpolation: linear
|
||||
lineWidth: 1
|
||||
pointSize: 5
|
||||
scaleDistribution:
|
||||
type: linear
|
||||
showPoints: auto
|
||||
showValues: false
|
||||
spanNulls: false
|
||||
stacking:
|
||||
group: A
|
||||
mode: none
|
||||
thresholdsStyle:
|
||||
mode: 'off'
|
||||
min: 0
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: 0
|
||||
- color: red
|
||||
value: 80
|
||||
unit: ms
|
||||
overrides: []
|
||||
options:
|
||||
annotations:
|
||||
clustering: -1
|
||||
multiLane: false
|
||||
legend:
|
||||
calcs: []
|
||||
displayMode: list
|
||||
placement: bottom
|
||||
showLegend: true
|
||||
tooltip:
|
||||
hideZeros: false
|
||||
mode: single
|
||||
sort: none
|
||||
version: 13.2.0-30616302309
|
||||
panel-2:
|
||||
kind: Panel
|
||||
spec:
|
||||
data:
|
||||
kind: QueryGroup
|
||||
spec:
|
||||
queries:
|
||||
- kind: PanelQuery
|
||||
spec:
|
||||
hidden: false
|
||||
query:
|
||||
datasource:
|
||||
name: bdgu7tk03irr4e
|
||||
group: vertamedia-clickhouse-datasource
|
||||
kind: DataQuery
|
||||
spec:
|
||||
dateTimeType: DATETIME
|
||||
editorMode: builder
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formatAs: time_series
|
||||
formattedQuery: "SELECT toStartOfInterval(timestamp, INTERVAL\
|
||||
\ '$interval' SECOND) as date, sum(double1) as processed, sum(double2)\
|
||||
\ as failed\n\tFROM 'MEASURE'\n\tWHERE timestamp >= toDateTime($from)\
|
||||
\ AND timestamp <= toDateTime($to)\n\tAND blob2 = '${environment}-tldraw-multiplayer'\n\
|
||||
\tAND blob1 = 'outbox_drain'\n\tGROUP BY date\n\tORDER BY date\
|
||||
\ ASC"
|
||||
intervalFactor: 1
|
||||
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
|
||||
\ SECOND) as date, sum(double1) as processed, sum(double2) as\
|
||||
\ failed\n\tFROM 'MEASURE'\n\tWHERE timestamp >= toDateTime($from)\
|
||||
\ AND timestamp <= toDateTime($to)\n\tAND blob2 = '${environment}-tldraw-multiplayer'\n\
|
||||
\tAND blob1 = 'outbox_drain'\n\tGROUP BY date\n\tORDER BY date\
|
||||
\ ASC"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
|
||||
\ SECOND) as date, sum(double1) as processed, sum(double2) as\
|
||||
\ failed\n\tFROM 'MEASURE'\n\tWHERE timestamp >= toDateTime(1762170000)\
|
||||
\ AND timestamp <= toDateTime(1762180000)\n\tAND blob2 = 'production-tldraw-multiplayer'\n\
|
||||
\tAND blob1 = 'outbox_drain'\n\tGROUP BY date\n\tORDER BY date\
|
||||
\ ASC"
|
||||
round: 0s
|
||||
showFormattedSQL: true
|
||||
skip_comments: true
|
||||
step: ''
|
||||
version: v0
|
||||
refId: A
|
||||
queryOptions: {}
|
||||
transformations: []
|
||||
description: Per-drain summary. double1 = rows processed, double2 = rows failed
|
||||
this drain.
|
||||
id: 2
|
||||
links: []
|
||||
title: Outbox throughput (processed / failed)
|
||||
vizConfig:
|
||||
group: timeseries
|
||||
kind: VizConfig
|
||||
spec:
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: palette-classic
|
||||
custom:
|
||||
axisBorderShow: false
|
||||
axisCenteredZero: false
|
||||
axisColorMode: text
|
||||
axisLabel: ''
|
||||
axisPlacement: right
|
||||
barAlignment: 0
|
||||
barWidthFactor: 0.6
|
||||
drawStyle: line
|
||||
fillOpacity: 0
|
||||
gradientMode: none
|
||||
hideFrom:
|
||||
legend: false
|
||||
tooltip: false
|
||||
viz: false
|
||||
insertNulls: false
|
||||
lineInterpolation: linear
|
||||
lineWidth: 1
|
||||
pointSize: 5
|
||||
scaleDistribution:
|
||||
type: linear
|
||||
showPoints: auto
|
||||
showValues: false
|
||||
spanNulls: false
|
||||
stacking:
|
||||
group: A
|
||||
mode: none
|
||||
thresholdsStyle:
|
||||
mode: 'off'
|
||||
min: 0
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: 0
|
||||
- color: red
|
||||
value: 80
|
||||
unit: short
|
||||
overrides: []
|
||||
options:
|
||||
annotations:
|
||||
clustering: -1
|
||||
multiLane: false
|
||||
legend:
|
||||
calcs: []
|
||||
displayMode: list
|
||||
placement: bottom
|
||||
showLegend: true
|
||||
tooltip:
|
||||
hideZeros: false
|
||||
mode: single
|
||||
sort: none
|
||||
version: 13.2.0-30616302309
|
||||
panel-3:
|
||||
kind: Panel
|
||||
spec:
|
||||
data:
|
||||
kind: QueryGroup
|
||||
spec:
|
||||
queries:
|
||||
- kind: PanelQuery
|
||||
spec:
|
||||
hidden: false
|
||||
query:
|
||||
datasource:
|
||||
name: bdgu7tk03irr4e
|
||||
group: vertamedia-clickhouse-datasource
|
||||
kind: DataQuery
|
||||
spec:
|
||||
dateTimeType: DATETIME
|
||||
editorMode: builder
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formatAs: time_series
|
||||
formattedQuery: "SELECT toStartOfInterval(timestamp, INTERVAL\
|
||||
\ '$interval' SECOND) as date, blob4 as command, quantileWeighted(0.95)(double1,\
|
||||
\ _sample_interval) as p95_ms\n\tFROM 'MEASURE'\n\tWHERE timestamp\
|
||||
\ >= toDateTime($from) AND timestamp <= toDateTime($to)\n\t\
|
||||
AND blob2 = '${environment}-tldraw-multiplayer'\n\tAND blob1\
|
||||
\ = 'outbox_effect'\n\tGROUP BY date, command\n\tORDER BY date\
|
||||
\ ASC"
|
||||
intervalFactor: 1
|
||||
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
|
||||
\ SECOND) as date, blob4 as command, quantileWeighted(0.95)(double1,\
|
||||
\ _sample_interval) as p95_ms\n\tFROM 'MEASURE'\n\tWHERE timestamp\
|
||||
\ >= toDateTime($from) AND timestamp <= toDateTime($to)\n\t\
|
||||
AND blob2 = '${environment}-tldraw-multiplayer'\n\tAND blob1\
|
||||
\ = 'outbox_effect'\n\tGROUP BY date, command\n\tORDER BY date\
|
||||
\ ASC"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
|
||||
\ SECOND) as date, blob4 as command, quantileWeighted(0.95)(double1,\
|
||||
\ _sample_interval) as p95_ms\n\tFROM 'MEASURE'\n\tWHERE timestamp\
|
||||
\ >= toDateTime(1762170000) AND timestamp <= toDateTime(1762180000)\n\
|
||||
\tAND blob2 = 'production-tldraw-multiplayer'\n\tAND blob1 =\
|
||||
\ 'outbox_effect'\n\tGROUP BY date, command\n\tORDER BY date\
|
||||
\ ASC"
|
||||
round: 0s
|
||||
showFormattedSQL: true
|
||||
skip_comments: true
|
||||
step: ''
|
||||
version: v0
|
||||
refId: A
|
||||
queryOptions: {}
|
||||
transformations: []
|
||||
description: publish/unpublish ride the update command (awaitPersist) and
|
||||
are the slowest. blob3=table, blob4=command.
|
||||
id: 3
|
||||
links: []
|
||||
title: Outbox effect latency p95 by command
|
||||
vizConfig:
|
||||
group: timeseries
|
||||
kind: VizConfig
|
||||
spec:
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: palette-classic
|
||||
custom:
|
||||
axisBorderShow: false
|
||||
axisCenteredZero: false
|
||||
axisColorMode: text
|
||||
axisLabel: ''
|
||||
axisPlacement: right
|
||||
barAlignment: 0
|
||||
barWidthFactor: 0.6
|
||||
drawStyle: line
|
||||
fillOpacity: 0
|
||||
gradientMode: none
|
||||
hideFrom:
|
||||
legend: false
|
||||
tooltip: false
|
||||
viz: false
|
||||
insertNulls: false
|
||||
lineInterpolation: linear
|
||||
lineWidth: 1
|
||||
pointSize: 5
|
||||
scaleDistribution:
|
||||
type: linear
|
||||
showPoints: auto
|
||||
showValues: false
|
||||
spanNulls: false
|
||||
stacking:
|
||||
group: A
|
||||
mode: none
|
||||
thresholdsStyle:
|
||||
mode: 'off'
|
||||
min: 0
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: 0
|
||||
- color: red
|
||||
value: 80
|
||||
unit: ms
|
||||
overrides: []
|
||||
options:
|
||||
annotations:
|
||||
clustering: -1
|
||||
multiLane: false
|
||||
legend:
|
||||
calcs: []
|
||||
displayMode: list
|
||||
placement: bottom
|
||||
showLegend: true
|
||||
tooltip:
|
||||
hideZeros: false
|
||||
mode: single
|
||||
sort: none
|
||||
version: 13.2.0-30616302309
|
||||
panel-4:
|
||||
kind: Panel
|
||||
spec:
|
||||
data:
|
||||
kind: QueryGroup
|
||||
spec:
|
||||
queries:
|
||||
- kind: PanelQuery
|
||||
spec:
|
||||
hidden: false
|
||||
query:
|
||||
datasource:
|
||||
name: bdgu7tk03irr4e
|
||||
group: vertamedia-clickhouse-datasource
|
||||
kind: DataQuery
|
||||
spec:
|
||||
dateTimeType: DATETIME
|
||||
editorMode: builder
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formatAs: time_series
|
||||
formattedQuery: "SELECT toStartOfInterval(timestamp, INTERVAL\
|
||||
\ '$interval' SECOND) as date, quantileWeighted(0.95)(double3,\
|
||||
\ _sample_interval) as drain_p95_ms\n\tFROM 'MEASURE'\n\tWHERE\
|
||||
\ timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)\n\
|
||||
\tAND blob2 = '${environment}-tldraw-multiplayer'\n\tAND blob1\
|
||||
\ = 'outbox_drain'\n\tGROUP BY date\n\tORDER BY date ASC"
|
||||
intervalFactor: 1
|
||||
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
|
||||
\ SECOND) as date, quantileWeighted(0.95)(double3, _sample_interval)\
|
||||
\ as drain_p95_ms\n\tFROM 'MEASURE'\n\tWHERE timestamp >= toDateTime($from)\
|
||||
\ AND timestamp <= toDateTime($to)\n\tAND blob2 = '${environment}-tldraw-multiplayer'\n\
|
||||
\tAND blob1 = 'outbox_drain'\n\tGROUP BY date\n\tORDER BY date\
|
||||
\ ASC"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
|
||||
\ SECOND) as date, quantileWeighted(0.95)(double3, _sample_interval)\
|
||||
\ as drain_p95_ms\n\tFROM 'MEASURE'\n\tWHERE timestamp >= toDateTime(1762170000)\
|
||||
\ AND timestamp <= toDateTime(1762180000)\n\tAND blob2 = 'production-tldraw-multiplayer'\n\
|
||||
\tAND blob1 = 'outbox_drain'\n\tGROUP BY date\n\tORDER BY date\
|
||||
\ ASC"
|
||||
round: 0s
|
||||
showFormattedSQL: true
|
||||
skip_comments: true
|
||||
step: ''
|
||||
version: v0
|
||||
refId: A
|
||||
queryOptions: {}
|
||||
transformations: []
|
||||
description: double3 = drainMs. Rising with flat throughput = individual effects
|
||||
getting slower.
|
||||
id: 4
|
||||
links: []
|
||||
title: Outbox drain duration p95
|
||||
vizConfig:
|
||||
group: timeseries
|
||||
kind: VizConfig
|
||||
spec:
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: palette-classic
|
||||
custom:
|
||||
axisBorderShow: false
|
||||
axisCenteredZero: false
|
||||
axisColorMode: text
|
||||
axisLabel: ''
|
||||
axisPlacement: right
|
||||
barAlignment: 0
|
||||
barWidthFactor: 0.6
|
||||
drawStyle: line
|
||||
fillOpacity: 0
|
||||
gradientMode: none
|
||||
hideFrom:
|
||||
legend: false
|
||||
tooltip: false
|
||||
viz: false
|
||||
insertNulls: false
|
||||
lineInterpolation: linear
|
||||
lineWidth: 1
|
||||
pointSize: 5
|
||||
scaleDistribution:
|
||||
type: linear
|
||||
showPoints: auto
|
||||
showValues: false
|
||||
spanNulls: false
|
||||
stacking:
|
||||
group: A
|
||||
mode: none
|
||||
thresholdsStyle:
|
||||
mode: 'off'
|
||||
min: 0
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: 0
|
||||
- color: red
|
||||
value: 80
|
||||
unit: ms
|
||||
overrides: []
|
||||
options:
|
||||
annotations:
|
||||
clustering: -1
|
||||
multiLane: false
|
||||
legend:
|
||||
calcs: []
|
||||
displayMode: list
|
||||
placement: bottom
|
||||
showLegend: true
|
||||
tooltip:
|
||||
hideZeros: false
|
||||
mode: single
|
||||
sort: none
|
||||
version: 13.2.0-30616302309
|
||||
panel-5:
|
||||
kind: Panel
|
||||
spec:
|
||||
data:
|
||||
kind: QueryGroup
|
||||
spec:
|
||||
queries:
|
||||
- kind: PanelQuery
|
||||
spec:
|
||||
hidden: false
|
||||
query:
|
||||
datasource:
|
||||
name: bdgu7tk03irr4e
|
||||
group: vertamedia-clickhouse-datasource
|
||||
kind: DataQuery
|
||||
spec:
|
||||
dateTimeType: DATETIME
|
||||
editorMode: builder
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formatAs: time_series
|
||||
formattedQuery: "SELECT toStartOfInterval(timestamp, INTERVAL\
|
||||
\ '$interval' SECOND) as date, blob4 as command, count() as\
|
||||
\ total\n\tFROM 'MEASURE'\n\tWHERE timestamp >= toDateTime($from)\
|
||||
\ AND timestamp <= toDateTime($to)\n\tAND blob2 = '${environment}-tldraw-multiplayer'\n\
|
||||
\tAND blob1 = 'outbox_effect'\n\tGROUP BY date, command\n\t\
|
||||
ORDER BY date ASC"
|
||||
intervalFactor: 1
|
||||
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
|
||||
\ SECOND) as date, blob4 as command, count() as total\n\tFROM\
|
||||
\ 'MEASURE'\n\tWHERE timestamp >= toDateTime($from) AND timestamp\
|
||||
\ <= toDateTime($to)\n\tAND blob2 = '${environment}-tldraw-multiplayer'\n\
|
||||
\tAND blob1 = 'outbox_effect'\n\tGROUP BY date, command\n\t\
|
||||
ORDER BY date ASC"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
|
||||
\ SECOND) as date, blob4 as command, count() as total\n\tFROM\
|
||||
\ 'MEASURE'\n\tWHERE timestamp >= toDateTime(1762170000) AND\
|
||||
\ timestamp <= toDateTime(1762180000)\n\tAND blob2 = 'production-tldraw-multiplayer'\n\
|
||||
\tAND blob1 = 'outbox_effect'\n\tGROUP BY date, command\n\t\
|
||||
ORDER BY date ASC"
|
||||
round: 0s
|
||||
showFormattedSQL: true
|
||||
skip_comments: true
|
||||
step: ''
|
||||
version: v0
|
||||
refId: A
|
||||
queryOptions: {}
|
||||
transformations: []
|
||||
description: Volume of each effect type (insert / update / delete).
|
||||
id: 5
|
||||
links: []
|
||||
title: Outbox effects by command
|
||||
vizConfig:
|
||||
group: timeseries
|
||||
kind: VizConfig
|
||||
spec:
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: palette-classic
|
||||
custom:
|
||||
axisBorderShow: false
|
||||
axisCenteredZero: false
|
||||
axisColorMode: text
|
||||
axisLabel: ''
|
||||
axisPlacement: right
|
||||
barAlignment: 0
|
||||
barWidthFactor: 0.6
|
||||
drawStyle: line
|
||||
fillOpacity: 0
|
||||
gradientMode: none
|
||||
hideFrom:
|
||||
legend: false
|
||||
tooltip: false
|
||||
viz: false
|
||||
insertNulls: false
|
||||
lineInterpolation: linear
|
||||
lineWidth: 1
|
||||
pointSize: 5
|
||||
scaleDistribution:
|
||||
type: linear
|
||||
showPoints: auto
|
||||
showValues: false
|
||||
spanNulls: false
|
||||
stacking:
|
||||
group: A
|
||||
mode: none
|
||||
thresholdsStyle:
|
||||
mode: 'off'
|
||||
min: 0
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: 0
|
||||
- color: red
|
||||
value: 80
|
||||
unit: short
|
||||
overrides: []
|
||||
options:
|
||||
annotations:
|
||||
clustering: -1
|
||||
multiLane: false
|
||||
legend:
|
||||
calcs: []
|
||||
displayMode: list
|
||||
placement: bottom
|
||||
showLegend: true
|
||||
tooltip:
|
||||
hideZeros: false
|
||||
mode: single
|
||||
sort: none
|
||||
version: 13.2.0-30616302309
|
||||
panel-6:
|
||||
kind: Panel
|
||||
spec:
|
||||
data:
|
||||
kind: QueryGroup
|
||||
spec:
|
||||
queries:
|
||||
- kind: PanelQuery
|
||||
spec:
|
||||
hidden: false
|
||||
query:
|
||||
datasource:
|
||||
name: bdgu7tk03irr4e
|
||||
group: vertamedia-clickhouse-datasource
|
||||
kind: DataQuery
|
||||
spec:
|
||||
dateTimeType: DATETIME
|
||||
editorMode: builder
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formatAs: time_series
|
||||
formattedQuery: "SELECT toStartOfInterval(timestamp, INTERVAL\
|
||||
\ '$interval' SECOND) as date, blob4 as command, count() as\
|
||||
\ parked\n\tFROM 'MEASURE'\n\tWHERE timestamp >= toDateTime($from)\
|
||||
\ AND timestamp <= toDateTime($to)\n\tAND blob2 = '${environment}-tldraw-multiplayer'\n\
|
||||
\tAND blob1 = 'outbox_parked'\n\tGROUP BY date, command\n\t\
|
||||
ORDER BY date ASC"
|
||||
intervalFactor: 1
|
||||
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
|
||||
\ SECOND) as date, blob4 as command, count() as parked\n\tFROM\
|
||||
\ 'MEASURE'\n\tWHERE timestamp >= toDateTime($from) AND timestamp\
|
||||
\ <= toDateTime($to)\n\tAND blob2 = '${environment}-tldraw-multiplayer'\n\
|
||||
\tAND blob1 = 'outbox_parked'\n\tGROUP BY date, command\n\t\
|
||||
ORDER BY date ASC"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
|
||||
\ SECOND) as date, blob4 as command, count() as parked\n\tFROM\
|
||||
\ 'MEASURE'\n\tWHERE timestamp >= toDateTime(1762170000) AND\
|
||||
\ timestamp <= toDateTime(1762180000)\n\tAND blob2 = 'production-tldraw-multiplayer'\n\
|
||||
\tAND blob1 = 'outbox_parked'\n\tGROUP BY date, command\n\t\
|
||||
ORDER BY date ASC"
|
||||
round: 0s
|
||||
showFormattedSQL: true
|
||||
skip_comments: true
|
||||
step: ''
|
||||
version: v0
|
||||
refId: A
|
||||
queryOptions: {}
|
||||
transformations: []
|
||||
description: A parked effect (10 failed attempts) is a data-loss event and
|
||||
also fires Sentry. Any line here = investigate.
|
||||
id: 6
|
||||
links: []
|
||||
title: Outbox parked effects (should be 0)
|
||||
vizConfig:
|
||||
group: timeseries
|
||||
kind: VizConfig
|
||||
spec:
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: palette-classic
|
||||
custom:
|
||||
axisBorderShow: false
|
||||
axisCenteredZero: false
|
||||
axisColorMode: text
|
||||
axisLabel: ''
|
||||
axisPlacement: right
|
||||
barAlignment: 0
|
||||
barWidthFactor: 0.6
|
||||
drawStyle: line
|
||||
fillOpacity: 0
|
||||
gradientMode: none
|
||||
hideFrom:
|
||||
legend: false
|
||||
tooltip: false
|
||||
viz: false
|
||||
insertNulls: false
|
||||
lineInterpolation: linear
|
||||
lineWidth: 1
|
||||
pointSize: 5
|
||||
scaleDistribution:
|
||||
type: linear
|
||||
showPoints: auto
|
||||
showValues: false
|
||||
spanNulls: false
|
||||
stacking:
|
||||
group: A
|
||||
mode: none
|
||||
thresholdsStyle:
|
||||
mode: 'off'
|
||||
min: 0
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: 0
|
||||
- color: red
|
||||
value: 80
|
||||
unit: short
|
||||
overrides: []
|
||||
options:
|
||||
annotations:
|
||||
clustering: -1
|
||||
multiLane: false
|
||||
legend:
|
||||
calcs: []
|
||||
displayMode: list
|
||||
placement: bottom
|
||||
showLegend: true
|
||||
tooltip:
|
||||
hideZeros: false
|
||||
mode: single
|
||||
sort: none
|
||||
version: 13.2.0-30616302309
|
||||
layout:
|
||||
kind: GridLayout
|
||||
spec:
|
||||
items:
|
||||
- kind: GridLayoutItem
|
||||
spec:
|
||||
element:
|
||||
kind: ElementReference
|
||||
name: panel-1
|
||||
height: 8
|
||||
width: 12
|
||||
x: 0
|
||||
y: 0
|
||||
- kind: GridLayoutItem
|
||||
spec:
|
||||
element:
|
||||
kind: ElementReference
|
||||
name: panel-2
|
||||
height: 8
|
||||
width: 12
|
||||
x: 12
|
||||
y: 0
|
||||
- kind: GridLayoutItem
|
||||
spec:
|
||||
element:
|
||||
kind: ElementReference
|
||||
name: panel-3
|
||||
height: 8
|
||||
width: 12
|
||||
x: 0
|
||||
y: 8
|
||||
- kind: GridLayoutItem
|
||||
spec:
|
||||
element:
|
||||
kind: ElementReference
|
||||
name: panel-4
|
||||
height: 8
|
||||
width: 12
|
||||
x: 12
|
||||
y: 8
|
||||
- kind: GridLayoutItem
|
||||
spec:
|
||||
element:
|
||||
kind: ElementReference
|
||||
name: panel-5
|
||||
height: 8
|
||||
width: 12
|
||||
x: 0
|
||||
y: 16
|
||||
- kind: GridLayoutItem
|
||||
spec:
|
||||
element:
|
||||
kind: ElementReference
|
||||
name: panel-6
|
||||
height: 8
|
||||
width: 12
|
||||
x: 12
|
||||
y: 16
|
||||
links: []
|
||||
liveNow: false
|
||||
preload: false
|
||||
tags:
|
||||
- dotcom
|
||||
- outbox
|
||||
- sync-worker
|
||||
- provisioned
|
||||
- repo:tldraw/tldraw
|
||||
timeSettings:
|
||||
autoRefresh: ''
|
||||
autoRefreshIntervals:
|
||||
- 5s
|
||||
- 10s
|
||||
- 30s
|
||||
- 1m
|
||||
- 5m
|
||||
- 15m
|
||||
- 30m
|
||||
- 1h
|
||||
- 2h
|
||||
- 1d
|
||||
fiscalYearStartMonth: 0
|
||||
from: now-6h
|
||||
hideTimepicker: false
|
||||
timezone: browser
|
||||
to: now
|
||||
title: File effect outbox
|
||||
variables:
|
||||
- kind: TextVariable
|
||||
spec:
|
||||
current:
|
||||
text: production
|
||||
value: production
|
||||
description: Environment to filter by. Use production, staging, or your pr number
|
||||
(pr-42).
|
||||
hide: dontHide
|
||||
label: 'Environment: production | staging | pr-42'
|
||||
name: environment
|
||||
query: production
|
||||
skipUrlSync: false
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,940 @@
|
||||
apiVersion: dashboard.grafana.app/v2beta1
|
||||
kind: Dashboard
|
||||
metadata:
|
||||
annotations:
|
||||
grafana.app/folder: dotcom
|
||||
grafana.app/saved-from-ui: Grafana Cloud (steady)
|
||||
labels: {}
|
||||
name: kotx6wj
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
annotations:
|
||||
- kind: AnnotationQuery
|
||||
spec:
|
||||
builtIn: true
|
||||
enable: true
|
||||
hide: true
|
||||
iconColor: rgba(0, 211, 255, 1)
|
||||
name: Annotations & Alerts
|
||||
query:
|
||||
datasource:
|
||||
name: -- Grafana --
|
||||
group: grafana
|
||||
kind: DataQuery
|
||||
spec: {}
|
||||
version: v0
|
||||
cursorSync: 'Off'
|
||||
description: How many sessions have been created with the MCP App
|
||||
editable: true
|
||||
elements:
|
||||
panel-1:
|
||||
kind: Panel
|
||||
spec:
|
||||
data:
|
||||
kind: QueryGroup
|
||||
spec:
|
||||
queries:
|
||||
- kind: PanelQuery
|
||||
spec:
|
||||
hidden: false
|
||||
query:
|
||||
datasource:
|
||||
name: bdgu7tk03irr4e
|
||||
group: vertamedia-clickhouse-datasource
|
||||
kind: DataQuery
|
||||
spec:
|
||||
dateTimeType: DATETIME
|
||||
editorMode: builder
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formattedQuery: " SELECT toStartOfInterval(timestamp, INTERVAL\
|
||||
\ '$interval' SECOND) as date, blob1 as event, count() as total\n\
|
||||
\ FROM 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime($from)\
|
||||
\ AND timestamp <= toDateTime($to)\n AND blob1 = 'session_start'\n\
|
||||
\ GROUP BY date, event\n ORDER BY date ASC\n"
|
||||
intervalFactor: 1
|
||||
query: " SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
|
||||
\ SECOND) as date, blob1 as event, count() as total\n FROM\
|
||||
\ 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime($from) AND\
|
||||
\ timestamp <= toDateTime($to)\n AND blob1 = 'session_start'\n\
|
||||
\ GROUP BY date, event\n ORDER BY date ASC\n"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
|
||||
\ SECOND) as date, blob1 as event, count() as total\n FROM\
|
||||
\ 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime(1772623318)\
|
||||
\ AND timestamp <= toDateTime(1772796118)\n AND blob1 = 'session_start'\n\
|
||||
\ GROUP BY date, event\n ORDER BY date ASC"
|
||||
round: 0s
|
||||
skip_comments: true
|
||||
version: v0
|
||||
refId: A
|
||||
queryOptions: {}
|
||||
transformations: []
|
||||
description: Sessions are created when you make a request to the MCP worker
|
||||
id: 1
|
||||
links: []
|
||||
title: Sessions by Time
|
||||
vizConfig:
|
||||
group: timeseries
|
||||
kind: VizConfig
|
||||
spec:
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: palette-classic
|
||||
custom:
|
||||
axisBorderShow: false
|
||||
axisCenteredZero: false
|
||||
axisColorMode: text
|
||||
axisLabel: ''
|
||||
axisPlacement: auto
|
||||
barAlignment: 0
|
||||
barWidthFactor: 0.6
|
||||
drawStyle: line
|
||||
fillOpacity: 0
|
||||
gradientMode: none
|
||||
hideFrom:
|
||||
legend: false
|
||||
tooltip: false
|
||||
viz: false
|
||||
insertNulls: false
|
||||
lineInterpolation: linear
|
||||
lineWidth: 1
|
||||
pointSize: 5
|
||||
scaleDistribution:
|
||||
type: linear
|
||||
showPoints: auto
|
||||
showValues: false
|
||||
spanNulls: false
|
||||
stacking:
|
||||
group: A
|
||||
mode: none
|
||||
thresholdsStyle:
|
||||
mode: 'off'
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: 0
|
||||
overrides: []
|
||||
options:
|
||||
legend:
|
||||
calcs: []
|
||||
displayMode: list
|
||||
placement: bottom
|
||||
showLegend: true
|
||||
tooltip:
|
||||
hideZeros: false
|
||||
mode: single
|
||||
sort: none
|
||||
version: 13.0.0-22478626928
|
||||
panel-4:
|
||||
kind: Panel
|
||||
spec:
|
||||
data:
|
||||
kind: QueryGroup
|
||||
spec:
|
||||
queries:
|
||||
- kind: PanelQuery
|
||||
spec:
|
||||
hidden: false
|
||||
query:
|
||||
datasource:
|
||||
name: bdgu7tk03irr4e
|
||||
group: vertamedia-clickhouse-datasource
|
||||
kind: DataQuery
|
||||
spec:
|
||||
dateTimeType: DATETIME
|
||||
editorMode: builder
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formattedQuery: " SELECT toStartOfInterval(timestamp, INTERVAL\
|
||||
\ '$interval' SECOND) as date, blob2 as tool, count() as total\n\
|
||||
\ FROM 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime($from)\
|
||||
\ AND timestamp <= toDateTime($to)\n AND blob1 = 'tool_called'\n\
|
||||
\ and blob2 = 'exec'\n GROUP BY date, tool\n ORDER\
|
||||
\ BY date ASC\n"
|
||||
intervalFactor: 1
|
||||
query: " SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
|
||||
\ SECOND) as date, blob2 as tool, count() as total\n FROM\
|
||||
\ 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime($from)\
|
||||
\ AND timestamp <= toDateTime($to)\n AND blob1 = 'tool_called'\n\
|
||||
\ and blob2 = 'exec'\n GROUP BY date, tool\n ORDER\
|
||||
\ BY date ASC\n"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
|
||||
\ SECOND) as date, blob2 as tool, count() as total\n FROM\
|
||||
\ 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime(1772623318)\
|
||||
\ AND timestamp <= toDateTime(1772796118)\n AND blob1 = 'tool_called'\n\
|
||||
\ and blob2 = 'exec'\n GROUP BY date, tool\n ORDER\
|
||||
\ BY date ASC"
|
||||
round: 0s
|
||||
skip_comments: true
|
||||
version: v0
|
||||
refId: A
|
||||
queryOptions: {}
|
||||
transformations: []
|
||||
description: tool_called events for the exec tool.
|
||||
id: 4
|
||||
links: []
|
||||
title: Tool call (exec)
|
||||
vizConfig:
|
||||
group: stat
|
||||
kind: VizConfig
|
||||
spec:
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: thresholds
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: blue
|
||||
value: 0
|
||||
overrides: []
|
||||
options:
|
||||
colorMode: value
|
||||
graphMode: area
|
||||
justifyMode: center
|
||||
orientation: auto
|
||||
percentChangeColorMode: standard
|
||||
reduceOptions:
|
||||
calcs:
|
||||
- sum
|
||||
fields: ''
|
||||
values: false
|
||||
showPercentChange: false
|
||||
textMode: auto
|
||||
wideLayout: true
|
||||
version: 13.0.0-22478626928
|
||||
panel-2:
|
||||
kind: Panel
|
||||
spec:
|
||||
data:
|
||||
kind: QueryGroup
|
||||
spec:
|
||||
queries:
|
||||
- kind: PanelQuery
|
||||
spec:
|
||||
hidden: false
|
||||
query:
|
||||
datasource:
|
||||
name: bdgu7tk03irr4e
|
||||
group: vertamedia-clickhouse-datasource
|
||||
kind: DataQuery
|
||||
spec:
|
||||
dateTimeType: DATETIME
|
||||
editorMode: builder
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formattedQuery: " SELECT toStartOfInterval(timestamp, INTERVAL\
|
||||
\ '$interval' SECOND) as date, blob2 as tool, count() as total\n\
|
||||
\ FROM 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime($from)\
|
||||
\ AND timestamp <= toDateTime($to)\n AND blob1 = 'tool_called'\n\
|
||||
\ and blob2 = 'search'\n GROUP BY date, tool\n ORDER\
|
||||
\ BY date ASC\n"
|
||||
intervalFactor: 1
|
||||
query: " SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
|
||||
\ SECOND) as date, blob2 as tool, count() as total\n FROM\
|
||||
\ 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime($from)\
|
||||
\ AND timestamp <= toDateTime($to)\n AND blob1 = 'tool_called'\n\
|
||||
\ and blob2 = 'search'\n GROUP BY date, tool\n\
|
||||
\ ORDER BY date ASC\n"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
|
||||
\ SECOND) as date, blob2 as tool, count() as total\n FROM\
|
||||
\ 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime(1772623318)\
|
||||
\ AND timestamp <= toDateTime(1772796118)\n AND blob1 = 'tool_called'\n\
|
||||
\ and blob2 = 'search'\n GROUP BY date, tool\n\
|
||||
\ ORDER BY date ASC"
|
||||
round: 0s
|
||||
skip_comments: true
|
||||
version: v0
|
||||
refId: A
|
||||
queryOptions: {}
|
||||
transformations: []
|
||||
description: tool_called events for the search tool.
|
||||
id: 2
|
||||
links: []
|
||||
title: Tool call (search)
|
||||
vizConfig:
|
||||
group: stat
|
||||
kind: VizConfig
|
||||
spec:
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: thresholds
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: 0
|
||||
overrides: []
|
||||
options:
|
||||
colorMode: value
|
||||
graphMode: area
|
||||
justifyMode: center
|
||||
orientation: auto
|
||||
percentChangeColorMode: standard
|
||||
reduceOptions:
|
||||
calcs:
|
||||
- sum
|
||||
fields: ''
|
||||
values: false
|
||||
showPercentChange: false
|
||||
textMode: auto
|
||||
wideLayout: true
|
||||
version: 13.0.0-22478626928
|
||||
panel-6:
|
||||
kind: Panel
|
||||
spec:
|
||||
data:
|
||||
kind: QueryGroup
|
||||
spec:
|
||||
queries:
|
||||
- kind: PanelQuery
|
||||
spec:
|
||||
hidden: false
|
||||
query:
|
||||
datasource:
|
||||
name: bdgu7tk03irr4e
|
||||
group: vertamedia-clickhouse-datasource
|
||||
kind: DataQuery
|
||||
spec:
|
||||
dateTimeType: DATETIME
|
||||
editorMode: builder
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formattedQuery: " SELECT toStartOfInterval(timestamp, INTERVAL\
|
||||
\ '$interval' SECOND) as date, blob1 as event, count() as total\n\
|
||||
\ FROM 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime($from)\
|
||||
\ AND timestamp <= toDateTime($to)\n AND blob1 = 'session_start'\n\
|
||||
\ GROUP BY date, event\n ORDER BY date ASC\n"
|
||||
intervalFactor: 1
|
||||
query: " SELECT toStartOfInterval(timestamp, INTERVAL '$interval'\
|
||||
\ SECOND) as date, blob1 as event, count() as total\n FROM\
|
||||
\ 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime($from) AND\
|
||||
\ timestamp <= toDateTime($to)\n AND blob1 = 'session_start'\n\
|
||||
\ GROUP BY date, event\n ORDER BY date ASC\n"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '120'\
|
||||
\ SECOND) as date, blob1 as event, count() as total\n FROM\
|
||||
\ 'MCP_ANALYTICS'\n WHERE timestamp >= toDateTime(1772622707)\
|
||||
\ AND timestamp <= toDateTime(1772795507)\n AND blob1 = 'session_start'\n\
|
||||
\ GROUP BY date, event\n ORDER BY date ASC"
|
||||
round: 0s
|
||||
skip_comments: true
|
||||
version: v0
|
||||
refId: A
|
||||
queryOptions: {}
|
||||
transformations: []
|
||||
description: Sessions are created when you make a request to the MCP worker
|
||||
id: 6
|
||||
links: []
|
||||
title: Total Sessions
|
||||
vizConfig:
|
||||
group: stat
|
||||
kind: VizConfig
|
||||
spec:
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: thresholds
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: 0
|
||||
overrides: []
|
||||
options:
|
||||
colorMode: value
|
||||
graphMode: area
|
||||
justifyMode: auto
|
||||
orientation: auto
|
||||
percentChangeColorMode: standard
|
||||
reduceOptions:
|
||||
calcs:
|
||||
- sum
|
||||
fields: ''
|
||||
values: false
|
||||
showPercentChange: false
|
||||
textMode: auto
|
||||
wideLayout: true
|
||||
version: 13.0.0-22478626928
|
||||
panel-7:
|
||||
kind: Panel
|
||||
spec:
|
||||
data:
|
||||
kind: QueryGroup
|
||||
spec:
|
||||
queries:
|
||||
- kind: PanelQuery
|
||||
spec:
|
||||
hidden: false
|
||||
query:
|
||||
datasource:
|
||||
name: bdgu7tk03irr4e
|
||||
group: vertamedia-clickhouse-datasource
|
||||
kind: DataQuery
|
||||
spec:
|
||||
dateTimeType: DATETIME
|
||||
editorMode: code
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formatAs: time_series
|
||||
intervalFactor: 1
|
||||
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'
|
||||
SECOND) as date, count() as sessions
|
||||
|
||||
FROM 'MCP_ANALYTICS'
|
||||
|
||||
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
|
||||
|
||||
AND blob1 = 'session_start'
|
||||
|
||||
GROUP BY date
|
||||
|
||||
ORDER BY date ASC"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'
|
||||
SECOND) as date, count() as sessions
|
||||
|
||||
FROM 'MCP_ANALYTICS'
|
||||
|
||||
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
|
||||
|
||||
AND blob1 = 'session_start'
|
||||
|
||||
GROUP BY date
|
||||
|
||||
ORDER BY date ASC"
|
||||
round: 0s
|
||||
showFormattedSQL: false
|
||||
showHelp: false
|
||||
skip_comments: true
|
||||
step: ''
|
||||
version: v0
|
||||
refId: A
|
||||
queryOptions: {}
|
||||
transformations: []
|
||||
description: session_start events from the tldraw MCP server (MCP_ANALYTICS
|
||||
dataset). Requires the MCP_ANALYTICS table in this datasource.
|
||||
id: 7
|
||||
links: []
|
||||
title: MCP sessions started
|
||||
vizConfig:
|
||||
group: timeseries
|
||||
kind: VizConfig
|
||||
spec:
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: palette-classic
|
||||
custom:
|
||||
axisBorderShow: false
|
||||
axisCenteredZero: false
|
||||
axisColorMode: text
|
||||
axisLabel: ''
|
||||
axisPlacement: auto
|
||||
barAlignment: 0
|
||||
barWidthFactor: 0.8
|
||||
drawStyle: bars
|
||||
fillOpacity: 80
|
||||
gradientMode: none
|
||||
hideFrom:
|
||||
legend: false
|
||||
tooltip: false
|
||||
viz: false
|
||||
insertNulls: false
|
||||
lineInterpolation: linear
|
||||
lineWidth: 0
|
||||
pointSize: 5
|
||||
scaleDistribution:
|
||||
type: linear
|
||||
showPoints: never
|
||||
showValues: false
|
||||
spanNulls: false
|
||||
stacking:
|
||||
group: A
|
||||
mode: none
|
||||
thresholdsStyle:
|
||||
mode: 'off'
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: 0
|
||||
overrides: []
|
||||
options:
|
||||
annotations:
|
||||
clustering: -1
|
||||
multiLane: false
|
||||
legend:
|
||||
calcs: []
|
||||
displayMode: list
|
||||
enableFacetedFilter: false
|
||||
overflow: ellipsis
|
||||
placement: bottom
|
||||
showLegend: false
|
||||
tooltip:
|
||||
hideZeros: false
|
||||
maxHeight: 600
|
||||
mode: multi
|
||||
sort: desc
|
||||
version: 13.2.0-29854286369
|
||||
panel-10:
|
||||
kind: Panel
|
||||
spec:
|
||||
data:
|
||||
kind: QueryGroup
|
||||
spec:
|
||||
queries:
|
||||
- kind: PanelQuery
|
||||
spec:
|
||||
hidden: false
|
||||
query:
|
||||
datasource:
|
||||
name: bdgu7tk03irr4e
|
||||
group: vertamedia-clickhouse-datasource
|
||||
kind: DataQuery
|
||||
spec:
|
||||
dateTimeType: DATETIME
|
||||
editorMode: code
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formatAs: time_series
|
||||
intervalFactor: 1
|
||||
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'
|
||||
SECOND) as date, count() as sessions
|
||||
|
||||
FROM 'MCP_ANALYTICS'
|
||||
|
||||
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
|
||||
|
||||
AND blob1 = 'session_end'
|
||||
|
||||
GROUP BY date
|
||||
|
||||
ORDER BY date ASC"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'
|
||||
SECOND) as date, count() as sessions
|
||||
|
||||
FROM 'MCP_ANALYTICS'
|
||||
|
||||
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
|
||||
|
||||
AND blob1 = 'session_end'
|
||||
|
||||
GROUP BY date
|
||||
|
||||
ORDER BY date ASC"
|
||||
round: 0s
|
||||
showFormattedSQL: false
|
||||
showHelp: false
|
||||
skip_comments: true
|
||||
step: ''
|
||||
version: v0
|
||||
refId: A
|
||||
queryOptions: {}
|
||||
transformations: []
|
||||
description: session_end events from the tldraw MCP server (MCP_ANALYTICS
|
||||
dataset). Requires the MCP_ANALYTICS table in this datasource.
|
||||
id: 10
|
||||
links: []
|
||||
title: MCP sessions ended
|
||||
vizConfig:
|
||||
group: timeseries
|
||||
kind: VizConfig
|
||||
spec:
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: palette-classic
|
||||
custom:
|
||||
axisBorderShow: false
|
||||
axisCenteredZero: false
|
||||
axisColorMode: text
|
||||
axisLabel: ''
|
||||
axisPlacement: auto
|
||||
barAlignment: 0
|
||||
barWidthFactor: 0.8
|
||||
drawStyle: bars
|
||||
fillOpacity: 80
|
||||
gradientMode: none
|
||||
hideFrom:
|
||||
legend: false
|
||||
tooltip: false
|
||||
viz: false
|
||||
insertNulls: false
|
||||
lineInterpolation: linear
|
||||
lineWidth: 0
|
||||
pointSize: 5
|
||||
scaleDistribution:
|
||||
type: linear
|
||||
showPoints: never
|
||||
showValues: false
|
||||
spanNulls: false
|
||||
stacking:
|
||||
group: A
|
||||
mode: none
|
||||
thresholdsStyle:
|
||||
mode: 'off'
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: 0
|
||||
overrides: []
|
||||
options:
|
||||
annotations:
|
||||
clustering: -1
|
||||
multiLane: false
|
||||
legend:
|
||||
calcs: []
|
||||
displayMode: list
|
||||
enableFacetedFilter: false
|
||||
overflow: ellipsis
|
||||
placement: bottom
|
||||
showLegend: false
|
||||
tooltip:
|
||||
hideZeros: false
|
||||
maxHeight: 600
|
||||
mode: multi
|
||||
sort: desc
|
||||
version: 13.2.0-29854286369
|
||||
panel-8:
|
||||
kind: Panel
|
||||
spec:
|
||||
data:
|
||||
kind: QueryGroup
|
||||
spec:
|
||||
queries:
|
||||
- kind: PanelQuery
|
||||
spec:
|
||||
hidden: false
|
||||
query:
|
||||
datasource:
|
||||
name: bdgu7tk03irr4e
|
||||
group: vertamedia-clickhouse-datasource
|
||||
kind: DataQuery
|
||||
spec:
|
||||
dateTimeType: DATETIME
|
||||
editorMode: code
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formatAs: time_series
|
||||
intervalFactor: 1
|
||||
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'
|
||||
SECOND) as date, blob2 as tool, count() as calls
|
||||
|
||||
FROM 'MCP_ANALYTICS'
|
||||
|
||||
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
|
||||
|
||||
AND blob1 = 'tool_called'
|
||||
|
||||
GROUP BY date, tool
|
||||
|
||||
ORDER BY date ASC"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'
|
||||
SECOND) as date, blob2 as tool, count() as calls
|
||||
|
||||
FROM 'MCP_ANALYTICS'
|
||||
|
||||
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
|
||||
|
||||
AND blob1 = 'tool_called'
|
||||
|
||||
GROUP BY date, tool
|
||||
|
||||
ORDER BY date ASC"
|
||||
round: 0s
|
||||
showFormattedSQL: false
|
||||
showHelp: false
|
||||
skip_comments: true
|
||||
step: ''
|
||||
version: v0
|
||||
refId: A
|
||||
queryOptions: {}
|
||||
transformations: []
|
||||
description: tool_called events split by tool name (search, exec).
|
||||
id: 8
|
||||
links: []
|
||||
title: MCP tool calls by tool
|
||||
vizConfig:
|
||||
group: timeseries
|
||||
kind: VizConfig
|
||||
spec:
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: palette-classic
|
||||
custom:
|
||||
axisBorderShow: false
|
||||
axisCenteredZero: false
|
||||
axisColorMode: text
|
||||
axisLabel: ''
|
||||
axisPlacement: auto
|
||||
barAlignment: 0
|
||||
barWidthFactor: 0.8
|
||||
drawStyle: bars
|
||||
fillOpacity: 80
|
||||
gradientMode: none
|
||||
hideFrom:
|
||||
legend: false
|
||||
tooltip: false
|
||||
viz: false
|
||||
insertNulls: false
|
||||
lineInterpolation: linear
|
||||
lineWidth: 0
|
||||
pointSize: 5
|
||||
scaleDistribution:
|
||||
type: linear
|
||||
showPoints: never
|
||||
showValues: false
|
||||
spanNulls: false
|
||||
stacking:
|
||||
group: A
|
||||
mode: normal
|
||||
thresholdsStyle:
|
||||
mode: 'off'
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: 0
|
||||
overrides: []
|
||||
options:
|
||||
annotations:
|
||||
clustering: -1
|
||||
multiLane: false
|
||||
legend:
|
||||
calcs: []
|
||||
displayMode: list
|
||||
enableFacetedFilter: false
|
||||
overflow: ellipsis
|
||||
placement: bottom
|
||||
showLegend: true
|
||||
tooltip:
|
||||
hideZeros: false
|
||||
maxHeight: 600
|
||||
mode: multi
|
||||
sort: desc
|
||||
version: 13.2.0-29854286369
|
||||
panel-9:
|
||||
kind: Panel
|
||||
spec:
|
||||
data:
|
||||
kind: QueryGroup
|
||||
spec:
|
||||
queries:
|
||||
- kind: PanelQuery
|
||||
spec:
|
||||
hidden: false
|
||||
query:
|
||||
datasource:
|
||||
name: bdgu7tk03irr4e
|
||||
group: vertamedia-clickhouse-datasource
|
||||
kind: DataQuery
|
||||
spec:
|
||||
dateTimeType: DATETIME
|
||||
editorMode: code
|
||||
extrapolate: true
|
||||
format: time_series
|
||||
formatAs: time_series
|
||||
intervalFactor: 1
|
||||
query: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'
|
||||
SECOND) as date, count() as reads
|
||||
|
||||
FROM 'MCP_ANALYTICS'
|
||||
|
||||
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
|
||||
|
||||
AND blob1 = 'resource_called'
|
||||
|
||||
GROUP BY date
|
||||
|
||||
ORDER BY date ASC"
|
||||
rawQuery: "SELECT toStartOfInterval(timestamp, INTERVAL '$interval'
|
||||
SECOND) as date, count() as reads
|
||||
|
||||
FROM 'MCP_ANALYTICS'
|
||||
|
||||
WHERE timestamp >= toDateTime($from) AND timestamp <= toDateTime($to)
|
||||
|
||||
AND blob1 = 'resource_called'
|
||||
|
||||
GROUP BY date
|
||||
|
||||
ORDER BY date ASC"
|
||||
round: 0s
|
||||
showFormattedSQL: false
|
||||
showHelp: false
|
||||
skip_comments: true
|
||||
step: ''
|
||||
version: v0
|
||||
refId: A
|
||||
queryOptions: {}
|
||||
transformations: []
|
||||
description: resource_called events for the tldraw-canvas UI resource.
|
||||
id: 9
|
||||
links: []
|
||||
title: MCP canvas resource reads
|
||||
vizConfig:
|
||||
group: timeseries
|
||||
kind: VizConfig
|
||||
spec:
|
||||
fieldConfig:
|
||||
defaults:
|
||||
color:
|
||||
mode: palette-classic
|
||||
custom:
|
||||
axisBorderShow: false
|
||||
axisCenteredZero: false
|
||||
axisColorMode: text
|
||||
axisLabel: ''
|
||||
axisPlacement: auto
|
||||
barAlignment: 0
|
||||
barWidthFactor: 0.8
|
||||
drawStyle: line
|
||||
fillOpacity: 25
|
||||
gradientMode: opacity
|
||||
hideFrom:
|
||||
legend: false
|
||||
tooltip: false
|
||||
viz: false
|
||||
insertNulls: false
|
||||
lineInterpolation: linear
|
||||
lineWidth: 1.5
|
||||
pointSize: 5
|
||||
scaleDistribution:
|
||||
type: linear
|
||||
showPoints: never
|
||||
showValues: false
|
||||
spanNulls: false
|
||||
stacking:
|
||||
group: A
|
||||
mode: none
|
||||
thresholdsStyle:
|
||||
mode: 'off'
|
||||
thresholds:
|
||||
mode: absolute
|
||||
steps:
|
||||
- color: green
|
||||
value: 0
|
||||
overrides: []
|
||||
options:
|
||||
annotations:
|
||||
clustering: -1
|
||||
multiLane: false
|
||||
legend:
|
||||
calcs: []
|
||||
displayMode: list
|
||||
enableFacetedFilter: false
|
||||
overflow: ellipsis
|
||||
placement: bottom
|
||||
showLegend: false
|
||||
tooltip:
|
||||
hideZeros: false
|
||||
maxHeight: 600
|
||||
mode: multi
|
||||
sort: desc
|
||||
version: 13.2.0-29854286369
|
||||
layout:
|
||||
kind: GridLayout
|
||||
spec:
|
||||
items:
|
||||
- kind: GridLayoutItem
|
||||
spec:
|
||||
element:
|
||||
kind: ElementReference
|
||||
name: panel-6
|
||||
height: 13
|
||||
width: 12
|
||||
x: 0
|
||||
y: 0
|
||||
- kind: GridLayoutItem
|
||||
spec:
|
||||
element:
|
||||
kind: ElementReference
|
||||
name: panel-1
|
||||
height: 13
|
||||
width: 12
|
||||
x: 12
|
||||
y: 0
|
||||
- kind: GridLayoutItem
|
||||
spec:
|
||||
element:
|
||||
kind: ElementReference
|
||||
name: panel-4
|
||||
height: 13
|
||||
width: 12
|
||||
x: 0
|
||||
y: 13
|
||||
- kind: GridLayoutItem
|
||||
spec:
|
||||
element:
|
||||
kind: ElementReference
|
||||
name: panel-2
|
||||
height: 13
|
||||
width: 12
|
||||
x: 12
|
||||
y: 13
|
||||
- kind: GridLayoutItem
|
||||
spec:
|
||||
element:
|
||||
kind: ElementReference
|
||||
name: panel-7
|
||||
height: 8
|
||||
width: 6
|
||||
x: 0
|
||||
y: 26
|
||||
- kind: GridLayoutItem
|
||||
spec:
|
||||
element:
|
||||
kind: ElementReference
|
||||
name: panel-10
|
||||
height: 8
|
||||
width: 6
|
||||
x: 6
|
||||
y: 26
|
||||
- kind: GridLayoutItem
|
||||
spec:
|
||||
element:
|
||||
kind: ElementReference
|
||||
name: panel-8
|
||||
height: 8
|
||||
width: 6
|
||||
x: 12
|
||||
y: 26
|
||||
- kind: GridLayoutItem
|
||||
spec:
|
||||
element:
|
||||
kind: ElementReference
|
||||
name: panel-9
|
||||
height: 8
|
||||
width: 6
|
||||
x: 18
|
||||
y: 26
|
||||
links: []
|
||||
liveNow: false
|
||||
preload: false
|
||||
tags:
|
||||
- provisioned
|
||||
- repo:tldraw/tldraw
|
||||
timeSettings:
|
||||
autoRefresh: ''
|
||||
autoRefreshIntervals:
|
||||
- 5s
|
||||
- 10s
|
||||
- 30s
|
||||
- 1m
|
||||
- 5m
|
||||
- 15m
|
||||
- 30m
|
||||
- 1h
|
||||
- 2h
|
||||
- 1d
|
||||
fiscalYearStartMonth: 0
|
||||
from: now-6h
|
||||
hideTimepicker: false
|
||||
timezone: browser
|
||||
to: now
|
||||
title: MCP app sessions
|
||||
variables: []
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: folder.grafana.app/v1
|
||||
kind: Folder
|
||||
metadata:
|
||||
annotations: {}
|
||||
labels: {}
|
||||
name: anthropic
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
title: Anthropic
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: folder.grafana.app/v1
|
||||
kind: Folder
|
||||
metadata:
|
||||
annotations: {}
|
||||
labels: {}
|
||||
name: dotcom
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
title: Dotcom
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: folder.grafana.app/v1
|
||||
kind: Folder
|
||||
metadata:
|
||||
annotations: {}
|
||||
labels: {}
|
||||
name: f6hsw6
|
||||
namespace: stacks-890472
|
||||
spec:
|
||||
title: Zero
|
||||
22
internal/grafana/templates/discord.gotmpl
Normal file
22
internal/grafana/templates/discord.gotmpl
Normal file
@@ -0,0 +1,22 @@
|
||||
{{ define "tldraw.discord.short" -}}
|
||||
{{ if eq .Labels.alertname "DatasourceNoData" }}No metrics: {{ .Labels.rulename }}{{ else if .Annotations.summary }}{{ .Annotations.summary }}{{ else }}{{ .Labels.alertname }}{{ end }}
|
||||
{{- end }}
|
||||
|
||||
{{ define "tldraw.discord.title" -}}
|
||||
{{ if eq .Status "firing" }}🔴{{ else }}✅{{ end }} {{ if gt (len .Alerts) 1 }}[{{ len .Alerts }} alerts] {{ end }}{{ template "tldraw.discord.short" (index .Alerts 0) }}
|
||||
{{- end }}
|
||||
|
||||
{{ define "tldraw.discord.alert" -}}
|
||||
{{ if eq .Labels.alertname "DatasourceNoData" -}}
|
||||
⚠️ **No metrics** for **{{ .Labels.rulename }}** (usually the metrics source is down - for Zero rules: Fly.io metrics - not the alert condition itself)
|
||||
{{- else -}}
|
||||
{{ with .Labels.severity }}**{{ . | toUpper }}** · {{ end }}{{ template "tldraw.discord.short" . }}
|
||||
{{- end }} · [View]({{ .GeneratorURL }}){{ with .DashboardURL }} · [Dashboard]({{ . }}){{ end }}
|
||||
{{- end }}
|
||||
|
||||
{{ define "tldraw.discord.text" -}}
|
||||
{{ range .Alerts.Firing }}{{ template "tldraw.discord.alert" . }}
|
||||
{{ end -}}
|
||||
{{ range .Alerts.Resolved }}✅ Resolved: {{ template "tldraw.discord.alert" . }}
|
||||
{{ end -}}
|
||||
{{- end }}
|
||||
26
internal/grafana/templates/email.gotmpl
Normal file
26
internal/grafana/templates/email.gotmpl
Normal file
@@ -0,0 +1,26 @@
|
||||
{{ define "tldraw.email.short" -}}
|
||||
{{ if eq .Labels.alertname "DatasourceNoData" }}No metrics: {{ .Labels.rulename }}{{ else if .Annotations.summary }}{{ .Annotations.summary }}{{ else }}{{ .Labels.alertname }}{{ end }}
|
||||
{{- end }}
|
||||
|
||||
{{ define "tldraw.email.subject" -}}
|
||||
{{ if eq .Status "firing" }}🔴{{ else }}✅{{ end }} {{ if gt (len .Alerts) 1 }}[{{ len .Alerts }} alerts] {{ end }}{{ template "tldraw.email.short" (index .Alerts 0) }}
|
||||
{{- end }}
|
||||
|
||||
{{ define "tldraw.email.alert" -}}
|
||||
{{ if eq .Labels.alertname "DatasourceNoData" -}}
|
||||
No metrics for {{ .Labels.rulename }} - the datasource returned no series. Usually the metrics source is down (for Zero rules: Fly.io metrics), not the alert condition itself.
|
||||
{{ else -}}
|
||||
{{ with .Labels.severity }}{{ . | toUpper }} · {{ end }}{{ template "tldraw.email.short" . }}
|
||||
{{ end -}}
|
||||
View: {{ .GeneratorURL }}
|
||||
Silence: {{ .SilenceURL }}
|
||||
{{- end }}
|
||||
|
||||
{{ define "tldraw.email.message" -}}
|
||||
{{ range .Alerts.Firing }}FIRING · {{ template "tldraw.email.alert" . }}
|
||||
|
||||
{{ end -}}
|
||||
{{ range .Alerts.Resolved }}RESOLVED · {{ template "tldraw.email.alert" . }}
|
||||
|
||||
{{ end -}}
|
||||
{{- end }}
|
||||
2
internal/health-worker/.gitignore
vendored
Normal file
2
internal/health-worker/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
build
|
||||
.wrangler
|
||||
27
internal/health-worker/README.md
Normal file
27
internal/health-worker/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Health Worker
|
||||
|
||||
Accepts webhooks from [Updown](https://updown.io/), sends them to our Discord.
|
||||
|
||||
## License
|
||||
|
||||
The code in this folder is Copyright (c) 2024-present tldraw Inc. The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
|
||||
|
||||
## Trademarks
|
||||
|
||||
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
|
||||
|
||||
## Distributions
|
||||
|
||||
You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions).
|
||||
|
||||
## Contribution
|
||||
|
||||
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
|
||||
|
||||
## Community
|
||||
|
||||
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
|
||||
|
||||
## Contact
|
||||
|
||||
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
|
||||
23
internal/health-worker/package.json
Normal file
23
internal/health-worker/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "health-worker",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev",
|
||||
"start": "wrangler dev",
|
||||
"check-bundle-size": "yarn run -T tsx ../scripts/check-worker-bundle.ts --entry src/index.ts --size-limit-bytes 40000",
|
||||
"lint": "yarn run -T tsx ../scripts/lint.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tldraw/utils": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260302.0",
|
||||
"@types/node": "^22.15.31",
|
||||
"discord-api-types": "^0.37.118",
|
||||
"esbuild": "^0.28.0",
|
||||
"typescript": "^7.0.2",
|
||||
"wrangler": "^4.75.0"
|
||||
}
|
||||
}
|
||||
96
internal/health-worker/src/discord.ts
Normal file
96
internal/health-worker/src/discord.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { exhaustiveSwitchError } from '@tldraw/utils'
|
||||
import { type APIEmbed } from 'discord-api-types/v10'
|
||||
import { Event as UpdownEvent } from './updown_types'
|
||||
|
||||
// discord wants decimal colours
|
||||
const GREEN = 4243543
|
||||
const RED = 14692657
|
||||
const ORANGE = 16213767
|
||||
|
||||
// docs: https://birdie0.github.io/discord-webhooks-guide/index.html
|
||||
export interface DiscordPayload {
|
||||
username: string
|
||||
content: string
|
||||
embeds: APIEmbed[]
|
||||
}
|
||||
|
||||
function formatUpdownEvent(event: UpdownEvent): {
|
||||
colour: number
|
||||
title: string
|
||||
description: string
|
||||
} | null {
|
||||
switch (event.event) {
|
||||
case 'check.down':
|
||||
return {
|
||||
colour: RED,
|
||||
title: `Check DOWN at <${event.check.url}>`,
|
||||
description: `<${event.check.url}> is down: "${event.downtime.error}"\n\nNext check in ${event.check.period} seconds`,
|
||||
}
|
||||
case 'check.still_down':
|
||||
return null
|
||||
case 'check.up': {
|
||||
return {
|
||||
colour: GREEN,
|
||||
title: `Check UP at <${event.check.url}>`,
|
||||
description: `<${event.check.url}> is up\n\nIt was down for ${event.downtime.duration} seconds`,
|
||||
}
|
||||
}
|
||||
case 'check.ssl_invalid': {
|
||||
return {
|
||||
colour: RED,
|
||||
title: `SSL INVALID at <${event.check.url}>`,
|
||||
description: `SSL certificate at <${event.check.url}> is invalid: "${event.ssl.error}"`,
|
||||
}
|
||||
}
|
||||
case 'check.ssl_valid': {
|
||||
return {
|
||||
colour: GREEN,
|
||||
title: `SSL VALID at <${event.check.url}>`,
|
||||
description: `SSL certificate at <${event.check.url}> is now valid`,
|
||||
}
|
||||
}
|
||||
case 'check.ssl_expiration': {
|
||||
return {
|
||||
colour: ORANGE,
|
||||
title: `SSL EXPIRATION at <${event.check.url}>`,
|
||||
description: `SSL certificate at <${event.check.url}> will expire in ${event.ssl.days_before_expiration} days`,
|
||||
}
|
||||
}
|
||||
|
||||
case 'check.ssl_renewed': {
|
||||
return {
|
||||
colour: GREEN,
|
||||
title: `SSL RENEWED at <${event.check.url}>`,
|
||||
description: `SSL certificate at <${event.check.url}> was renewed`,
|
||||
}
|
||||
}
|
||||
case 'check.performance_drop':
|
||||
return {
|
||||
colour: ORANGE,
|
||||
title: `PERFORMANCE DROP at <${event.check.url}>`,
|
||||
description: `Performance drop at <${event.check.url}>, apdex dropped ${event.apdex_dropped}`,
|
||||
}
|
||||
|
||||
default:
|
||||
exhaustiveSwitchError(event, 'event')
|
||||
}
|
||||
}
|
||||
|
||||
export function updownToDiscord(event: UpdownEvent): DiscordPayload | null {
|
||||
const formatted = formatUpdownEvent(event)
|
||||
if (!formatted) return null
|
||||
|
||||
const { colour, title, description } = formatted
|
||||
|
||||
return {
|
||||
username: 'Health Worker',
|
||||
content: `Updown: ${title}`,
|
||||
embeds: [
|
||||
{
|
||||
color: colour,
|
||||
description: description,
|
||||
timestamp: event.time,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
69
internal/health-worker/src/index.ts
Normal file
69
internal/health-worker/src/index.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { DiscordPayload, updownToDiscord } from './discord'
|
||||
import { Event as UpdownEvent } from './updown_types'
|
||||
|
||||
interface Env {
|
||||
DISCORD_HEALTH_WEBHOOK_URL: string | undefined
|
||||
// it needs to be passed in because it's effectively a secret, unless we want everyone to be able
|
||||
// to stress us out with spurious discord alerts
|
||||
HEALTH_WORKER_UPDOWN_WEBHOOK_PATH: string | undefined
|
||||
}
|
||||
|
||||
async function sendDiscordWebhook(url: string, discord: DiscordPayload): Promise<Response> {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(discord),
|
||||
})
|
||||
}
|
||||
|
||||
async function handleUpdown(request: Request, discordUrl: string): Promise<Response> {
|
||||
const updownEvents = (await request.json()) as Array<UpdownEvent>
|
||||
|
||||
let status = 200
|
||||
for (const e of updownEvents) {
|
||||
const discordPayload = updownToDiscord(e)
|
||||
if (!discordPayload) {
|
||||
continue
|
||||
}
|
||||
const discordResult = await sendDiscordWebhook(discordUrl, discordPayload)
|
||||
|
||||
if (!discordResult.ok) {
|
||||
console.error(`Discord error ${discordResult.status}: ${discordResult.statusText}`)
|
||||
status = discordResult.status
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(null, { status })
|
||||
}
|
||||
|
||||
const handler: ExportedHandler<Env> = {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const discordUrl = env.DISCORD_HEALTH_WEBHOOK_URL
|
||||
if (!discordUrl) {
|
||||
console.error('missing DISCORD_HEALTH_WEBHOOK_URL')
|
||||
return new Response('Internal error', { status: 500 })
|
||||
}
|
||||
|
||||
const updownWebhookPath = env.HEALTH_WORKER_UPDOWN_WEBHOOK_PATH
|
||||
if (!updownWebhookPath) {
|
||||
console.error('missing HEALTH_WORKER_UPDOWN_WEBHOOK_PATH')
|
||||
return new Response('Internal error', { status: 500 })
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
|
||||
// timing safety COULD be an issue, but it seems that in practice it isn't:
|
||||
// https://github.com/scriptin/node-timing-attack
|
||||
// my own testing confirms those observations
|
||||
if (url.pathname === updownWebhookPath) {
|
||||
return handleUpdown(request, discordUrl)
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 })
|
||||
},
|
||||
}
|
||||
|
||||
export default handler
|
||||
152
internal/health-worker/src/updown_types.ts
Normal file
152
internal/health-worker/src/updown_types.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// docs: https://updown.io/api#webhooks
|
||||
|
||||
interface BaseCheck {
|
||||
token: string
|
||||
url: string
|
||||
alias: null
|
||||
last_status: number
|
||||
uptime: number
|
||||
period: number
|
||||
apdex_t: number
|
||||
string_match: string
|
||||
enabled: boolean
|
||||
published: boolean
|
||||
disabled_locations: any[]
|
||||
recipients: any[]
|
||||
last_check_at: string
|
||||
next_check_at: string
|
||||
created_at: null
|
||||
mute_until: null | string
|
||||
favicon_url: string
|
||||
custom_headers: CustomHeaders
|
||||
http_verb: string
|
||||
http_body: string
|
||||
}
|
||||
|
||||
interface FailingCheck extends BaseCheck {
|
||||
down: true
|
||||
down_since: string
|
||||
up_since: null
|
||||
error: string
|
||||
}
|
||||
|
||||
interface SucceedingCheck extends BaseCheck {
|
||||
down: true
|
||||
down_since: null
|
||||
up_since: string
|
||||
error: null
|
||||
}
|
||||
|
||||
interface BaseDowntime {
|
||||
id: string
|
||||
error: string
|
||||
started_at: string
|
||||
partial: unknown
|
||||
}
|
||||
|
||||
interface OngoingDowntime extends BaseDowntime {
|
||||
ended_at: null
|
||||
duration: null
|
||||
}
|
||||
|
||||
interface FinishedDowntime extends BaseDowntime {
|
||||
ended_at: string
|
||||
// seconds
|
||||
duration: number
|
||||
}
|
||||
|
||||
type CustomHeaders = Record<string, string>
|
||||
|
||||
interface SslCert {
|
||||
subject: string
|
||||
issuer: string
|
||||
from: string
|
||||
to: string
|
||||
algorithm: string
|
||||
}
|
||||
|
||||
interface EventDown {
|
||||
event: 'check.down'
|
||||
time: string
|
||||
description: string
|
||||
check: FailingCheck
|
||||
downtime: OngoingDowntime
|
||||
}
|
||||
|
||||
interface EventStillDown {
|
||||
event: 'check.still_down'
|
||||
time: string
|
||||
description: string
|
||||
check: FailingCheck
|
||||
downtime: OngoingDowntime
|
||||
}
|
||||
|
||||
interface EventUp {
|
||||
event: 'check.up'
|
||||
time: string
|
||||
description: string
|
||||
check: SucceedingCheck
|
||||
downtime: FinishedDowntime
|
||||
}
|
||||
|
||||
interface EventSslInvalid {
|
||||
event: 'check.ssl_invalid'
|
||||
time: string
|
||||
description: string
|
||||
check: SucceedingCheck | FailingCheck
|
||||
ssl: {
|
||||
cert: SslCert
|
||||
error: string
|
||||
}
|
||||
}
|
||||
|
||||
interface EventSslValid {
|
||||
event: 'check.ssl_valid'
|
||||
time: string
|
||||
description: string
|
||||
check: SucceedingCheck | FailingCheck
|
||||
ssl: {
|
||||
cert: SslCert
|
||||
}
|
||||
}
|
||||
|
||||
interface EventSslExpiration {
|
||||
event: 'check.ssl_expiration'
|
||||
time: string
|
||||
description: string
|
||||
check: SucceedingCheck | FailingCheck
|
||||
ssl: {
|
||||
cert: SslCert
|
||||
days_before_expiration: number
|
||||
}
|
||||
}
|
||||
|
||||
interface EventSslRenewed {
|
||||
event: 'check.ssl_renewed'
|
||||
time: string
|
||||
description: string
|
||||
check: SucceedingCheck | FailingCheck
|
||||
ssl: {
|
||||
new_cert: SslCert
|
||||
old_cert: SslCert
|
||||
}
|
||||
}
|
||||
|
||||
interface EventPerformanceDrop {
|
||||
event: 'check.performance_drop'
|
||||
time: string
|
||||
description: string
|
||||
check: SucceedingCheck | FailingCheck
|
||||
apdex_dropped: string
|
||||
last_metrics: Record<string, { apdex: number }>
|
||||
}
|
||||
|
||||
export type Event =
|
||||
| EventDown
|
||||
| EventStillDown
|
||||
| EventUp
|
||||
| EventSslInvalid
|
||||
| EventSslValid
|
||||
| EventSslExpiration
|
||||
| EventSslRenewed
|
||||
| EventPerformanceDrop
|
||||
11
internal/health-worker/tsconfig.json
Normal file
11
internal/health-worker/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../config/tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", ".tsbuild*"],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"emitDeclarationOnly": false,
|
||||
"types": ["@cloudflare/workers-types", "@types/node"]
|
||||
},
|
||||
"references": [{ "path": "../../packages/utils" }]
|
||||
}
|
||||
17
internal/health-worker/wrangler.toml
Normal file
17
internal/health-worker/wrangler.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2023-12-18"
|
||||
|
||||
#################### Environment names ####################
|
||||
# dev should never actually get deployed anywhere
|
||||
[env.dev]
|
||||
name = "health-worker-dev"
|
||||
|
||||
# we don't have a hard-coded name for preview. we instead have to generate it at build time and append it to this file.
|
||||
|
||||
# staging is the same as a preview on main:
|
||||
[env.staging]
|
||||
name = "health-worker-staging"
|
||||
|
||||
# production gets the proper name
|
||||
[env.production]
|
||||
name = "health-worker-production"
|
||||
102
internal/scripts/api-check.ts
Executable file
102
internal/scripts/api-check.ts
Executable file
@@ -0,0 +1,102 @@
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import { join, resolve } from 'path'
|
||||
import { exec } from './lib/exec'
|
||||
import { readFileIfExists } from './lib/file'
|
||||
import { nicelog } from './lib/nicelog'
|
||||
import { getAllWorkspacePackages } from './lib/workspace'
|
||||
|
||||
const packagesOurTypesCanDependOn = [
|
||||
'@types/lodash.throttle',
|
||||
'@types/lodash.uniq',
|
||||
'@types/lodash.isequal',
|
||||
'@types/lodash.isequalwith',
|
||||
'@types/react',
|
||||
'@types/react-dom',
|
||||
'eventemitter3',
|
||||
'nanoevents',
|
||||
'react',
|
||||
'react-dom',
|
||||
]
|
||||
|
||||
// These packages use subpath exports which require moduleResolution: "bundler".
|
||||
// We pin to the workspace's resolved versions to avoid installing broken releases.
|
||||
const pinnedPackages = [
|
||||
'@tiptap/core',
|
||||
'@tiptap/extension-list',
|
||||
'@tiptap/extension-mention',
|
||||
'@tiptap/pm',
|
||||
'@tiptap/react',
|
||||
'@tiptap/starter-kit',
|
||||
'@tiptap/suggestion',
|
||||
]
|
||||
|
||||
function getPinnedVersions(): string[] {
|
||||
return pinnedPackages.map((pkg) => {
|
||||
const pkgJsonPath = resolve(`./node_modules/${pkg}/package.json`)
|
||||
const { version } = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
|
||||
return `${pkg}@${version}`
|
||||
})
|
||||
}
|
||||
|
||||
function getTempDeclarationFilename(packageName: string): string {
|
||||
// Keep filenames unique across similarly named packages like `tldraw` and `@tldraw/tldraw`.
|
||||
return (
|
||||
packageName
|
||||
.replace(/^@/, '')
|
||||
.replace(/\//g, '__')
|
||||
.replace(/[^a-zA-Z0-9_.-]/g, '_') + '.d.ts'
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
async function main() {
|
||||
const tsconfig: any = {
|
||||
compilerOptions: {
|
||||
lib: ['esnext', 'dom'],
|
||||
strict: true,
|
||||
module: 'esnext',
|
||||
moduleResolution: 'bundler',
|
||||
rootDir: '.',
|
||||
paths: {},
|
||||
esModuleInterop: true,
|
||||
},
|
||||
files: [],
|
||||
}
|
||||
|
||||
const tempDir = (await exec('mktemp', ['-d'])).trim()
|
||||
nicelog(`Working in ${tempDir}`)
|
||||
|
||||
const packages = (await getAllWorkspacePackages())
|
||||
.filter(({ packageJson }) => !packageJson.private)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
nicelog(
|
||||
'Checking packages:',
|
||||
packages.map(({ packageJson }) => packageJson.name)
|
||||
)
|
||||
|
||||
for (const { name, relativePath } of packages) {
|
||||
const declarationFilename = getTempDeclarationFilename(name)
|
||||
const dtsFile = await readFileIfExists(join(relativePath, 'api', 'public.d.ts'))
|
||||
if (!dtsFile) {
|
||||
nicelog(`No public.d.ts for ${name}, skipping`)
|
||||
continue
|
||||
}
|
||||
|
||||
writeFileSync(join(tempDir, declarationFilename), dtsFile, 'utf8')
|
||||
tsconfig.compilerOptions.paths[name] = [`./${declarationFilename}`]
|
||||
tsconfig.files.push(`./${declarationFilename}`)
|
||||
}
|
||||
|
||||
nicelog('Checking with tsconfig:', tsconfig)
|
||||
writeFileSync(`${tempDir}/tsconfig.json`, JSON.stringify(tsconfig, null, '\t'), 'utf8')
|
||||
writeFileSync(`${tempDir}/package.json`, JSON.stringify({ dependencies: {} }, null, '\t'), 'utf8')
|
||||
|
||||
await exec('npm', ['install', ...packagesOurTypesCanDependOn, ...getPinnedVersions()], {
|
||||
pwd: tempDir,
|
||||
})
|
||||
await exec(resolve('./node_modules/.bin/tsc'), [], { pwd: tempDir })
|
||||
|
||||
await exec('rm', ['-rf', tempDir])
|
||||
}
|
||||
292
internal/scripts/benchmark-dict-validator.ts
Normal file
292
internal/scripts/benchmark-dict-validator.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Benchmark script for @tldraw/validate DictValidator
|
||||
*
|
||||
* Usage:
|
||||
* yarn tsx internal/scripts/benchmark-dict-validator.ts
|
||||
*
|
||||
* This script measures the performance of the DictValidator in @tldraw/validate.
|
||||
* It tests both fresh validation and validateUsingKnownGoodVersion scenarios.
|
||||
*/
|
||||
|
||||
import * as T from '../../packages/validate/src/lib/validation'
|
||||
|
||||
const ITERATIONS_PER_RUN = 50_000
|
||||
const TOTAL_RUNS = 200
|
||||
|
||||
interface BenchmarkResult {
|
||||
first100Avg: number
|
||||
last100Avg: number
|
||||
opsPerSecFirst: number
|
||||
opsPerSecLast: number
|
||||
}
|
||||
|
||||
function runBenchmark(name: string, fn: () => void): BenchmarkResult {
|
||||
const times: number[] = []
|
||||
|
||||
for (let run = 0; run < TOTAL_RUNS; run++) {
|
||||
const start = performance.now()
|
||||
for (let i = 0; i < ITERATIONS_PER_RUN; i++) {
|
||||
fn()
|
||||
}
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
|
||||
const first100 = times.slice(0, 100)
|
||||
const last100 = times.slice(100)
|
||||
const first100Avg = first100.reduce((a, b) => a + b, 0) / first100.length
|
||||
const last100Avg = last100.reduce((a, b) => a + b, 0) / last100.length
|
||||
|
||||
return {
|
||||
first100Avg,
|
||||
last100Avg,
|
||||
opsPerSecFirst: (ITERATIONS_PER_RUN / first100Avg) * 1000,
|
||||
opsPerSecLast: (ITERATIONS_PER_RUN / last100Avg) * 1000,
|
||||
}
|
||||
}
|
||||
|
||||
function formatOps(ops: number): string {
|
||||
if (ops >= 1_000_000) {
|
||||
return `${(ops / 1_000_000).toFixed(2)}M`
|
||||
}
|
||||
if (ops >= 1_000) {
|
||||
return `${(ops / 1_000).toFixed(2)}K`
|
||||
}
|
||||
return ops.toFixed(0)
|
||||
}
|
||||
|
||||
function printResult(name: string, result: BenchmarkResult): void {
|
||||
const warmupChange = ((result.first100Avg / result.last100Avg - 1) * 100).toFixed(1)
|
||||
const warmupDir = Number(warmupChange) > 0 ? 'slower' : 'faster'
|
||||
|
||||
console.log(`--- ${name} ---`)
|
||||
console.log(
|
||||
` First 100 runs: ${result.first100Avg.toFixed(2)}ms (${formatOps(result.opsPerSecFirst)} ops/sec)`
|
||||
)
|
||||
console.log(
|
||||
` Last 100 runs: ${result.last100Avg.toFixed(2)}ms (${formatOps(result.opsPerSecLast)} ops/sec)`
|
||||
)
|
||||
console.log(` JIT stability: First 100 ${Math.abs(Number(warmupChange))}% ${warmupDir}`)
|
||||
console.log('')
|
||||
}
|
||||
|
||||
// Test data for dict validators
|
||||
// Small dict (3 keys) - common case for simple configs
|
||||
const smallDict: Record<string, number> = {
|
||||
width: 100,
|
||||
height: 200,
|
||||
depth: 50,
|
||||
}
|
||||
|
||||
// Medium dict (10 keys) - typical shape props
|
||||
const mediumDict: Record<string, number> = {}
|
||||
for (let i = 0; i < 10; i++) {
|
||||
mediumDict[`key${i}`] = i * 10
|
||||
}
|
||||
|
||||
// Large dict (50 keys) - stress test
|
||||
const largeDict: Record<string, number> = {}
|
||||
for (let i = 0; i < 50; i++) {
|
||||
largeDict[`key${i}`] = i * 10
|
||||
}
|
||||
|
||||
// Line shape points scenario - realistic use case
|
||||
interface LinePoint {
|
||||
id: string
|
||||
index: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
const linePoints: Record<string, LinePoint> = {}
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const id = `point${i}`
|
||||
linePoints[id] = { id, index: `a${i}`, x: i * 10, y: i * 20 }
|
||||
}
|
||||
|
||||
// Validators
|
||||
const stringToNumberDict = T.dict(T.string, T.number)
|
||||
const linePointValidator = T.object({
|
||||
id: T.string,
|
||||
index: T.string,
|
||||
x: T.number,
|
||||
y: T.number,
|
||||
})
|
||||
const linePointsDict = T.dict(T.string, linePointValidator)
|
||||
|
||||
console.log('='.repeat(70))
|
||||
console.log('DICT VALIDATOR BENCHMARK')
|
||||
console.log('='.repeat(70))
|
||||
console.log(`Iterations per run: ${ITERATIONS_PER_RUN.toLocaleString()}`)
|
||||
console.log(`Total runs: ${TOTAL_RUNS}`)
|
||||
console.log(`Comparing first 100 runs (warmup) vs last 100 runs (stable)`)
|
||||
console.log('')
|
||||
|
||||
// --- Fresh validation benchmarks ---
|
||||
console.log('='.repeat(70))
|
||||
console.log('FRESH VALIDATION (validate)')
|
||||
console.log('='.repeat(70))
|
||||
console.log('')
|
||||
|
||||
// Small dict fresh validation
|
||||
{
|
||||
const testDicts = [smallDict, { ...smallDict, extra: 999 }, { a: 1, b: 2, c: 3 }]
|
||||
let idx = 0
|
||||
printResult(
|
||||
'Small dict (3 keys)',
|
||||
runBenchmark('SmallDict', () => {
|
||||
stringToNumberDict.validate(testDicts[idx++ % testDicts.length])
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Medium dict fresh validation
|
||||
{
|
||||
const testDicts = [mediumDict, { ...mediumDict }]
|
||||
let idx = 0
|
||||
printResult(
|
||||
'Medium dict (10 keys)',
|
||||
runBenchmark('MediumDict', () => {
|
||||
stringToNumberDict.validate(testDicts[idx++ % testDicts.length])
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Large dict fresh validation
|
||||
{
|
||||
const testDicts = [largeDict, { ...largeDict }]
|
||||
let idx = 0
|
||||
printResult(
|
||||
'Large dict (50 keys)',
|
||||
runBenchmark('LargeDict', () => {
|
||||
stringToNumberDict.validate(testDicts[idx++ % testDicts.length])
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Line points dict (realistic scenario)
|
||||
{
|
||||
const testDicts = [linePoints, { ...linePoints }]
|
||||
let idx = 0
|
||||
printResult(
|
||||
'Line points dict (5 nested objects)',
|
||||
runBenchmark('LinePoints', () => {
|
||||
linePointsDict.validate(testDicts[idx++ % testDicts.length])
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// --- ValidateUsingKnownGoodVersion benchmarks ---
|
||||
console.log('='.repeat(70))
|
||||
console.log('INCREMENTAL VALIDATION (validateUsingKnownGoodVersion)')
|
||||
console.log('='.repeat(70))
|
||||
console.log('')
|
||||
|
||||
// Scenario 1: No changes (should be fast - identity check)
|
||||
{
|
||||
const knownGood = stringToNumberDict.validate(smallDict)
|
||||
printResult(
|
||||
'Small dict - no changes (same reference)',
|
||||
runBenchmark('SmallDictNoChange', () => {
|
||||
stringToNumberDict.validateUsingKnownGoodVersion(knownGood, knownGood)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Scenario 2: Same values, different reference
|
||||
{
|
||||
const knownGood = stringToNumberDict.validate(smallDict)
|
||||
const newValue = { ...smallDict } // Same values, new object
|
||||
printResult(
|
||||
'Small dict - same values, different reference',
|
||||
runBenchmark('SmallDictSameValues', () => {
|
||||
stringToNumberDict.validateUsingKnownGoodVersion(knownGood, newValue)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Scenario 3: One value changed
|
||||
{
|
||||
const knownGood = stringToNumberDict.validate(smallDict)
|
||||
const changed = { ...smallDict, width: 150 }
|
||||
printResult(
|
||||
'Small dict - one value changed',
|
||||
runBenchmark('SmallDictOneChange', () => {
|
||||
stringToNumberDict.validateUsingKnownGoodVersion(knownGood, changed)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Scenario 4: Key added
|
||||
{
|
||||
const knownGood = stringToNumberDict.validate(smallDict)
|
||||
const added = { ...smallDict, newKey: 999 }
|
||||
printResult(
|
||||
'Small dict - key added',
|
||||
runBenchmark('SmallDictKeyAdded', () => {
|
||||
stringToNumberDict.validateUsingKnownGoodVersion(knownGood, added)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Scenario 5: Key removed
|
||||
{
|
||||
const { width: _, ...rest } = smallDict
|
||||
const knownGood = stringToNumberDict.validate(smallDict)
|
||||
printResult(
|
||||
'Small dict - key removed',
|
||||
runBenchmark('SmallDictKeyRemoved', () => {
|
||||
stringToNumberDict.validateUsingKnownGoodVersion(knownGood, rest)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Medium dict scenarios
|
||||
{
|
||||
const knownGood = stringToNumberDict.validate(mediumDict)
|
||||
const newValue = { ...mediumDict }
|
||||
printResult(
|
||||
'Medium dict - same values, different reference',
|
||||
runBenchmark('MediumDictSameValues', () => {
|
||||
stringToNumberDict.validateUsingKnownGoodVersion(knownGood, newValue)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
const knownGood = stringToNumberDict.validate(mediumDict)
|
||||
const changed = { ...mediumDict, key5: 999 }
|
||||
printResult(
|
||||
'Medium dict - one value changed',
|
||||
runBenchmark('MediumDictOneChange', () => {
|
||||
stringToNumberDict.validateUsingKnownGoodVersion(knownGood, changed)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Line points incremental
|
||||
{
|
||||
const knownGood = linePointsDict.validate(linePoints)
|
||||
const newValue = { ...linePoints }
|
||||
printResult(
|
||||
'Line points - same values, different reference',
|
||||
runBenchmark('LinePointsSameValues', () => {
|
||||
linePointsDict.validateUsingKnownGoodVersion(knownGood, newValue)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
const knownGood = linePointsDict.validate(linePoints)
|
||||
const changed = {
|
||||
...linePoints,
|
||||
point2: { ...linePoints.point2, x: 999 },
|
||||
}
|
||||
printResult(
|
||||
'Line points - one point changed',
|
||||
runBenchmark('LinePointsOneChange', () => {
|
||||
linePointsDict.validateUsingKnownGoodVersion(knownGood, changed)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
console.log('='.repeat(70))
|
||||
console.log('')
|
||||
379
internal/scripts/benchmark-validators.ts
Normal file
379
internal/scripts/benchmark-validators.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Benchmark script for @tldraw/validate number validators
|
||||
*
|
||||
* Usage:
|
||||
* yarn tsx internal/scripts/benchmark-validators.ts
|
||||
*
|
||||
* This script measures the performance of the numeric validators in @tldraw/validate.
|
||||
* It runs multiple iterations to get stable measurements after JIT warmup.
|
||||
*/
|
||||
|
||||
import * as T from '../../packages/validate/src/lib/validation'
|
||||
|
||||
const ITERATIONS_PER_RUN = 100_000
|
||||
const TOTAL_RUNS = 200
|
||||
|
||||
interface BenchmarkResult {
|
||||
first100Avg: number
|
||||
last100Avg: number
|
||||
opsPerSecFirst: number
|
||||
opsPerSecLast: number
|
||||
}
|
||||
|
||||
function runBenchmark(name: string, fn: () => void): BenchmarkResult {
|
||||
const times: number[] = []
|
||||
|
||||
for (let run = 0; run < TOTAL_RUNS; run++) {
|
||||
const start = performance.now()
|
||||
for (let i = 0; i < ITERATIONS_PER_RUN; i++) {
|
||||
fn()
|
||||
}
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
|
||||
const first100 = times.slice(0, 100)
|
||||
const last100 = times.slice(100)
|
||||
const first100Avg = first100.reduce((a, b) => a + b, 0) / first100.length
|
||||
const last100Avg = last100.reduce((a, b) => a + b, 0) / last100.length
|
||||
|
||||
return {
|
||||
first100Avg,
|
||||
last100Avg,
|
||||
opsPerSecFirst: (ITERATIONS_PER_RUN / first100Avg) * 1000,
|
||||
opsPerSecLast: (ITERATIONS_PER_RUN / last100Avg) * 1000,
|
||||
}
|
||||
}
|
||||
|
||||
function formatOps(ops: number): string {
|
||||
if (ops >= 1_000_000) {
|
||||
return `${(ops / 1_000_000).toFixed(2)}M`
|
||||
}
|
||||
if (ops >= 1_000) {
|
||||
return `${(ops / 1_000).toFixed(2)}K`
|
||||
}
|
||||
return ops.toFixed(0)
|
||||
}
|
||||
|
||||
function printResult(name: string, result: BenchmarkResult): void {
|
||||
const warmupChange = ((result.first100Avg / result.last100Avg - 1) * 100).toFixed(1)
|
||||
const warmupDir = Number(warmupChange) > 0 ? 'slower' : 'faster'
|
||||
|
||||
console.log(`--- ${name} ---`)
|
||||
console.log(
|
||||
` First 100 runs: ${result.first100Avg.toFixed(2)}ms (${formatOps(result.opsPerSecFirst)} ops/sec)`
|
||||
)
|
||||
console.log(
|
||||
` Last 100 runs: ${result.last100Avg.toFixed(2)}ms (${formatOps(result.opsPerSecLast)} ops/sec)`
|
||||
)
|
||||
console.log(` JIT stability: First 100 ${Math.abs(Number(warmupChange))}% ${warmupDir}`)
|
||||
console.log('')
|
||||
}
|
||||
|
||||
// Test values
|
||||
const testIntegers = [0, 1, 42, -100, 1000000]
|
||||
const testFloats = [0.5, 3.14, -2.5, 100.001, 0.000001]
|
||||
const testPositive = [0, 1, 42, 0.5, 1000000]
|
||||
const testNonZero = [1, 42, 0.5, 100.001, 1000000]
|
||||
const testUnitInterval = [0, 0.25, 0.5, 0.75, 1]
|
||||
const testNonZeroFinite = [1, -1, 0.5, -0.5, 100.001]
|
||||
|
||||
console.log('='.repeat(70))
|
||||
console.log('VALIDATOR BENCHMARK')
|
||||
console.log('='.repeat(70))
|
||||
console.log(`Iterations per run: ${ITERATIONS_PER_RUN.toLocaleString()}`)
|
||||
console.log(`Total runs: ${TOTAL_RUNS}`)
|
||||
console.log(`Comparing first 100 runs (warmup) vs last 100 runs (stable)`)
|
||||
console.log('')
|
||||
|
||||
// T.number
|
||||
{
|
||||
const vals = [...testIntegers, ...testFloats]
|
||||
let idx = 0
|
||||
printResult(
|
||||
'T.number',
|
||||
runBenchmark('T.number', () => {
|
||||
T.number.validate(vals[idx++ % vals.length])
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// T.positiveNumber
|
||||
{
|
||||
let idx = 0
|
||||
printResult(
|
||||
'T.positiveNumber',
|
||||
runBenchmark('T.positiveNumber', () => {
|
||||
T.positiveNumber.validate(testPositive[idx++ % testPositive.length])
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// T.nonZeroNumber
|
||||
{
|
||||
let idx = 0
|
||||
printResult(
|
||||
'T.nonZeroNumber',
|
||||
runBenchmark('T.nonZeroNumber', () => {
|
||||
T.nonZeroNumber.validate(testNonZero[idx++ % testNonZero.length])
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// T.integer
|
||||
{
|
||||
let idx = 0
|
||||
printResult(
|
||||
'T.integer',
|
||||
runBenchmark('T.integer', () => {
|
||||
T.integer.validate(testIntegers[idx++ % testIntegers.length])
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// T.positiveInteger
|
||||
{
|
||||
let idx = 0
|
||||
printResult(
|
||||
'T.positiveInteger',
|
||||
runBenchmark('T.positiveInteger', () => {
|
||||
T.positiveInteger.validate(testPositive.filter(Number.isInteger)[idx++ % 3])
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// T.nonZeroFiniteNumber
|
||||
{
|
||||
let idx = 0
|
||||
printResult(
|
||||
'T.nonZeroFiniteNumber',
|
||||
runBenchmark('T.nonZeroFiniteNumber', () => {
|
||||
T.nonZeroFiniteNumber.validate(testNonZeroFinite[idx++ % testNonZeroFinite.length])
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// T.unitInterval
|
||||
{
|
||||
let idx = 0
|
||||
printResult(
|
||||
'T.unitInterval',
|
||||
runBenchmark('T.unitInterval', () => {
|
||||
T.unitInterval.validate(testUnitInterval[idx++ % testUnitInterval.length])
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
console.log('='.repeat(70))
|
||||
console.log('')
|
||||
|
||||
// Union validator benchmarks
|
||||
console.log('='.repeat(70))
|
||||
console.log('UNION VALIDATOR')
|
||||
console.log('='.repeat(70))
|
||||
console.log('')
|
||||
|
||||
const circleSchema = T.object({
|
||||
type: T.literal('circle'),
|
||||
radius: T.number,
|
||||
})
|
||||
const squareSchema = T.object({
|
||||
type: T.literal('square'),
|
||||
size: T.number,
|
||||
})
|
||||
const triangleSchema = T.object({
|
||||
type: T.literal('triangle'),
|
||||
base: T.number,
|
||||
height: T.number,
|
||||
})
|
||||
|
||||
const shapeUnion = T.union('type', {
|
||||
circle: circleSchema,
|
||||
square: squareSchema,
|
||||
triangle: triangleSchema,
|
||||
})
|
||||
|
||||
const testShapes = [
|
||||
{ type: 'circle', radius: 10 },
|
||||
{ type: 'square', size: 20 },
|
||||
{ type: 'triangle', base: 15, height: 25 },
|
||||
{ type: 'circle', radius: 5 },
|
||||
{ type: 'square', size: 30 },
|
||||
]
|
||||
|
||||
// Fresh validation
|
||||
{
|
||||
let idx = 0
|
||||
printResult(
|
||||
'Union fresh validation',
|
||||
runBenchmark('UnionFresh', () => {
|
||||
shapeUnion.validate(testShapes[idx++ % testShapes.length])
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// validateUsingKnownGoodVersion - same reference (tests Object.is fast path)
|
||||
{
|
||||
const knownGood = shapeUnion.validate({ type: 'circle', radius: 10 })
|
||||
printResult(
|
||||
'Union same reference (Object.is fast path)',
|
||||
runBenchmark('UnionSameRef', () => {
|
||||
shapeUnion.validateUsingKnownGoodVersion(knownGood, knownGood)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// validateUsingKnownGoodVersion - same type, value changed
|
||||
{
|
||||
const knownGood = shapeUnion.validate({ type: 'circle', radius: 10 })
|
||||
const changed = { type: 'circle', radius: 20 }
|
||||
printResult(
|
||||
'Union same type, value changed',
|
||||
runBenchmark('UnionSameType', () => {
|
||||
shapeUnion.validateUsingKnownGoodVersion(knownGood, changed)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// validateUsingKnownGoodVersion - different type
|
||||
{
|
||||
const knownGood = shapeUnion.validate({ type: 'circle', radius: 10 })
|
||||
const different = { type: 'square', size: 20 }
|
||||
printResult(
|
||||
'Union different type',
|
||||
runBenchmark('UnionDiffType', () => {
|
||||
shapeUnion.validateUsingKnownGoodVersion(knownGood, different)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Number union (tests optimized NaN check)
|
||||
const numberUnionSchema = T.numberUnion('version', {
|
||||
1: T.object({ version: T.literal(1), data: T.string }),
|
||||
2: T.object({ version: T.literal(2), data: T.string, extra: T.number }),
|
||||
})
|
||||
|
||||
const testNumberUnions = [
|
||||
{ version: 1, data: 'hello' },
|
||||
{ version: 2, data: 'world', extra: 42 },
|
||||
{ version: 1, data: 'foo' },
|
||||
{ version: 2, data: 'bar', extra: 100 },
|
||||
]
|
||||
|
||||
{
|
||||
let idx = 0
|
||||
printResult(
|
||||
'NumberUnion fresh validation',
|
||||
runBenchmark('NumberUnionFresh', () => {
|
||||
numberUnionSchema.validate(testNumberUnions[idx++ % testNumberUnions.length])
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
console.log('='.repeat(70))
|
||||
console.log('')
|
||||
|
||||
// Realistic scenario: Point validation
|
||||
console.log('='.repeat(70))
|
||||
console.log('REALISTIC SCENARIO: Point validation')
|
||||
console.log('='.repeat(70))
|
||||
console.log('')
|
||||
|
||||
const testPoints: unknown[] = []
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
testPoints.push({ x: Math.random() * 1000, y: Math.random() * 1000 })
|
||||
}
|
||||
|
||||
const pointValidator = T.object({
|
||||
x: T.number,
|
||||
y: T.number,
|
||||
})
|
||||
|
||||
{
|
||||
let idx = 0
|
||||
printResult(
|
||||
'Point {x, y}',
|
||||
runBenchmark('Point', () => {
|
||||
pointValidator.validate(testPoints[idx++ % testPoints.length])
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Draw shape props scenario
|
||||
const testDrawProps: unknown[] = []
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
testDrawProps.push({
|
||||
scaleX: 1 + Math.random() * 0.5,
|
||||
scaleY: 1 + Math.random() * 0.5,
|
||||
opacity: Math.random(),
|
||||
})
|
||||
}
|
||||
|
||||
const drawPropsValidator = T.object({
|
||||
scaleX: T.nonZeroFiniteNumber,
|
||||
scaleY: T.nonZeroFiniteNumber,
|
||||
opacity: T.unitInterval,
|
||||
})
|
||||
|
||||
{
|
||||
let idx = 0
|
||||
printResult(
|
||||
'DrawProps {scaleX, scaleY, opacity}',
|
||||
runBenchmark('DrawProps', () => {
|
||||
drawPropsValidator.validate(testDrawProps[idx++ % testDrawProps.length])
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
console.log('='.repeat(70))
|
||||
console.log('')
|
||||
|
||||
// Factory method caching benchmarks
|
||||
console.log('='.repeat(70))
|
||||
console.log('FACTORY METHOD CACHING')
|
||||
console.log('='.repeat(70))
|
||||
console.log('')
|
||||
|
||||
// Test repeated .nullable() calls on same validator
|
||||
{
|
||||
printResult(
|
||||
'T.string.nullable() repeated calls',
|
||||
runBenchmark('NullableRepeated', () => {
|
||||
T.string.nullable()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Test repeated .optional() calls on same validator
|
||||
{
|
||||
printResult(
|
||||
'T.string.optional() repeated calls',
|
||||
runBenchmark('OptionalRepeated', () => {
|
||||
T.string.optional()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Test chained .nullable().optional() calls
|
||||
{
|
||||
printResult(
|
||||
'T.number.nullable().optional() chain',
|
||||
runBenchmark('NullableOptionalChain', () => {
|
||||
T.number.nullable().optional()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Realistic: validate with nullable field
|
||||
{
|
||||
const nullableStringValidator = T.string.nullable()
|
||||
const testValues = ['hello', null, 'world', null, 'test']
|
||||
let idx = 0
|
||||
printResult(
|
||||
'Validate with cached nullable',
|
||||
runBenchmark('ValidateCachedNullable', () => {
|
||||
nullableStringValidator.validate(testValues[idx++ % testValues.length])
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
console.log('='.repeat(70))
|
||||
62
internal/scripts/build-api.ts
Normal file
62
internal/scripts/build-api.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import path from 'path'
|
||||
import { glob } from 'glob'
|
||||
import isCI from 'is-ci'
|
||||
import { rimraf } from 'rimraf'
|
||||
import { exec } from './lib/exec'
|
||||
import { sortUnions } from './lib/sort-unions'
|
||||
|
||||
async function buildApi(sourcePackageDir: string) {
|
||||
// this depends on `build-types` being run first, but we'll rely on turbo to
|
||||
// make that happen.
|
||||
|
||||
const relativeSourcePackageDir = path.relative(path.resolve(__dirname, '../..'), sourcePackageDir)
|
||||
|
||||
// we need to copy the .tsbuild dir to another location so we can edit it without busting the cache
|
||||
await rimraf(path.join(sourcePackageDir, '.tsbuild-api'))
|
||||
await exec('cp', ['-R', './.tsbuild', './.tsbuild-api'], { pwd: sourcePackageDir })
|
||||
|
||||
sortUnions(path.join(sourcePackageDir, '.tsbuild-api'))
|
||||
|
||||
// clear api-extractor build files
|
||||
rimraf.sync(glob.sync(path.join(sourcePackageDir, 'api')))
|
||||
// extract public api
|
||||
try {
|
||||
await exec('yarn', ['run', '-T', 'api-extractor', 'run', isCI ? null : '--local'], {
|
||||
pwd: sourcePackageDir,
|
||||
processStderrLine: (line) => {
|
||||
process.stderr.write(`${line}\n`)
|
||||
|
||||
if (process.env.PRINT_GITHUB_ANNOTATIONS) {
|
||||
const errorMatch = line.match(
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/^(?:\u001b\[33m)?(Warning|Error): (.+?):(\d+):(\d+) - \((.*)\) (.*)(?:\u001b\[39m)?$/
|
||||
)
|
||||
if (errorMatch) {
|
||||
const [, errorType, file, line, column, messageTitle, message] = errorMatch
|
||||
|
||||
process.stdout.write(
|
||||
`::${errorType.toLowerCase()} file=${relativeSourcePackageDir}/${file},line=${line},col=${column},title=${messageTitle}::${message}}\n`
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
// get git diff
|
||||
await exec('git', [
|
||||
'diff',
|
||||
'--no-color',
|
||||
'--no-ext-diff',
|
||||
'--no-index',
|
||||
'api-report.api.md',
|
||||
'api/temp/api-report.api.md',
|
||||
])
|
||||
|
||||
// @ts-expect-error
|
||||
console.error(e.message)
|
||||
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
buildApi(process.cwd())
|
||||
137
internal/scripts/build-package.ts
Normal file
137
internal/scripts/build-package.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { copyFileSync, existsSync } from 'fs'
|
||||
import path from 'path'
|
||||
import { pathToFileURL } from 'url'
|
||||
import { build } from 'esbuild'
|
||||
import { glob } from 'glob'
|
||||
import kleur from 'kleur'
|
||||
import rimraf from 'rimraf'
|
||||
import { addJsExtensions } from './lib/add-extensions'
|
||||
import { readJsonIfExists } from './lib/file'
|
||||
|
||||
interface LibraryInfo {
|
||||
name: string
|
||||
version: string
|
||||
moduleSystem: 'esm' | 'cjs'
|
||||
}
|
||||
|
||||
/** Prepares the package for publishing. the tarball in case it will be written to disk. */
|
||||
async function buildPackage({ sourcePackageDir }: { sourcePackageDir: string }) {
|
||||
// this depends on `build-types` being run first, but we'll rely on turbo to
|
||||
// make that happen.
|
||||
|
||||
if (!existsSync(path.join(sourcePackageDir, 'src/index.ts'))) {
|
||||
throw new Error(`No src/index.ts file found in '${sourcePackageDir}'!`)
|
||||
}
|
||||
|
||||
rimraf.sync(path.join(sourcePackageDir, 'dist'))
|
||||
|
||||
// then copy over the source .ts files
|
||||
const sourceFiles = glob
|
||||
.sync(path.join(sourcePackageDir, 'src/**/*.ts?(x)'))
|
||||
// ignore test files
|
||||
.filter(
|
||||
(file) =>
|
||||
!(file.includes('/__tests__/') || file.includes('.test.') || file.includes('/test/'))
|
||||
)
|
||||
|
||||
const packageJson = await readJsonIfExists(path.join(sourcePackageDir, 'package.json'))
|
||||
if (!packageJson) {
|
||||
throw new Error('No package.json found in source package dir')
|
||||
}
|
||||
|
||||
const packageName = packageJson.name
|
||||
if (typeof packageName !== 'string') throw new Error('package.json name is not a string')
|
||||
const packageVersion = packageJson.version
|
||||
if (typeof packageVersion !== 'string') throw new Error('package.json version is not a string')
|
||||
|
||||
// build CommonJS files to /dist-cjs
|
||||
await buildLibrary({
|
||||
sourceFiles,
|
||||
sourcePackageDir,
|
||||
info: {
|
||||
name: packageName,
|
||||
version: packageVersion,
|
||||
moduleSystem: 'cjs',
|
||||
},
|
||||
})
|
||||
|
||||
// build ES Module files to /dist-esm
|
||||
await buildLibrary({
|
||||
sourceFiles,
|
||||
sourcePackageDir,
|
||||
info: {
|
||||
name: packageName,
|
||||
version: packageVersion,
|
||||
moduleSystem: 'esm',
|
||||
},
|
||||
})
|
||||
|
||||
copyFileSync(
|
||||
path.join(sourcePackageDir, `api/public.d.ts`),
|
||||
path.join(sourcePackageDir, 'dist-cjs/index.d.ts')
|
||||
)
|
||||
|
||||
copyFileSync(
|
||||
path.join(sourcePackageDir, `api/public.d.ts`),
|
||||
path.join(sourcePackageDir, 'dist-esm/index.d.mts')
|
||||
)
|
||||
}
|
||||
|
||||
/** This uses esbuild to build the esm version of the package */
|
||||
async function buildLibrary({
|
||||
sourceFiles,
|
||||
sourcePackageDir,
|
||||
info,
|
||||
}: {
|
||||
sourceFiles: string[]
|
||||
sourcePackageDir: string
|
||||
info: LibraryInfo
|
||||
}) {
|
||||
const dirName = `dist-${info.moduleSystem}`
|
||||
const outdir = path.join(sourcePackageDir, dirName)
|
||||
rimraf.sync(outdir)
|
||||
|
||||
const define: Record<string, string> = {}
|
||||
if (process.env.TLDRAW_BEMO_URL) {
|
||||
define['process.env.TLDRAW_BEMO_URL'] = JSON.stringify(process.env.TLDRAW_BEMO_URL)
|
||||
}
|
||||
|
||||
// we use `globalThis` instead of `process.env` because although it'll get compiled away in
|
||||
// library code, we use the un-compiled versions in our own apps and not every environment
|
||||
// supports process.env.
|
||||
define['globalThis.TLDRAW_LIBRARY_NAME'] = JSON.stringify(info.name)
|
||||
define['globalThis.TLDRAW_LIBRARY_VERSION'] = JSON.stringify(info.version)
|
||||
define['globalThis.TLDRAW_LIBRARY_MODULES'] = JSON.stringify(info.moduleSystem)
|
||||
define['globalThis.TLDRAW_LIBRARY_IS_BUILD'] = JSON.stringify(true)
|
||||
|
||||
const res = await build({
|
||||
entryPoints: sourceFiles,
|
||||
outdir,
|
||||
format: info.moduleSystem,
|
||||
outExtension: info.moduleSystem === 'esm' ? { '.js': '.mjs' } : undefined,
|
||||
bundle: false,
|
||||
platform: 'neutral',
|
||||
sourcemap: true,
|
||||
target: 'es2022',
|
||||
define,
|
||||
})
|
||||
|
||||
if (info.moduleSystem === 'esm') {
|
||||
addJsExtensions(path.join(sourcePackageDir, 'dist-esm'))
|
||||
}
|
||||
|
||||
if (res.errors.length) {
|
||||
console.error(kleur.red('Build failed with errors:'))
|
||||
console.error(res.errors)
|
||||
throw new Error('esm build failed')
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
// module was called directly
|
||||
;(async () => {
|
||||
await buildPackage({
|
||||
sourcePackageDir: process.cwd(),
|
||||
})
|
||||
})()
|
||||
}
|
||||
23
internal/scripts/bump-versions.ts
Normal file
23
internal/scripts/bump-versions.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { exec } from './lib/exec'
|
||||
import { nicelog } from './lib/nicelog'
|
||||
import { getLatestTldrawVersionFromNpm, setAllVersions } from './lib/publishing'
|
||||
|
||||
async function main() {
|
||||
const latestVersion = await getLatestTldrawVersionFromNpm()
|
||||
nicelog('Bumping all package versions to', latestVersion.format())
|
||||
|
||||
await setAllVersions(latestVersion.format(), { stageChanges: true })
|
||||
|
||||
// Commit the changes with [skip ci] to prevent triggering other workflows
|
||||
await exec('git', ['commit', '-m', `Bump versions to ${latestVersion.format()} [skip ci]`])
|
||||
|
||||
// Push the changes
|
||||
await exec('git', ['push'])
|
||||
|
||||
nicelog('Successfully bumped versions and pushed changes')
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
nicelog('Error:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
237
internal/scripts/check-circular-deps.ts
Normal file
237
internal/scripts/check-circular-deps.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { access, readdir } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { REPO_ROOT } from './lib/file'
|
||||
import { nicelog } from './lib/nicelog'
|
||||
|
||||
const packageEntry = 'src/index.ts'
|
||||
|
||||
async function exists(filePath: string) {
|
||||
try {
|
||||
await access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function getPackageEntries() {
|
||||
const packagesDir = path.join(REPO_ROOT, 'packages')
|
||||
const packageNames = (await readdir(packagesDir)).sort()
|
||||
const entries: Record<string, string> = {}
|
||||
|
||||
for (const packageName of packageNames) {
|
||||
const entryPath = path.join(packagesDir, packageName, packageEntry)
|
||||
if (await exists(entryPath)) {
|
||||
entries[packageName] = entryPath
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
async function getIncludePatternSets(packageName: string) {
|
||||
const fullPackagePatternSet = [[`**/packages/${packageName}/**/*.{js,jsx,ts,tsx}`]]
|
||||
if (packageName !== 'tldraw') return fullPackagePatternSet
|
||||
|
||||
// NOTE:
|
||||
// `vite-plugin-circular-dependency` crashes with "Invalid array length" on tldraw when
|
||||
// `ui/components` and `ui/context` are scanned together in a single build graph.
|
||||
// To keep checks reliable and fast, we build tldraw in a few dynamic batches:
|
||||
// - all non-ui scopes together
|
||||
// - ui/components alone
|
||||
// - ui/context alone
|
||||
// - remaining ui scopes together
|
||||
// These batches are discovered from the filesystem to avoid brittle hardcoded scope lists.
|
||||
const tldrawLibDir = path.join(REPO_ROOT, 'packages', 'tldraw', 'src', 'lib')
|
||||
const tldrawEntries = (await readdir(tldrawLibDir, { withFileTypes: true })).sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
)
|
||||
|
||||
const nonUiPatterns: string[] = []
|
||||
let uiComponentsPattern: string | null = null
|
||||
let uiContextPattern: string | null = null
|
||||
const uiOtherPatterns: string[] = []
|
||||
|
||||
for (const entry of tldrawEntries.filter((entry) => !entry.name.startsWith('.'))) {
|
||||
if (!entry.isDirectory()) {
|
||||
if (/\.(ts|tsx|js|jsx)$/.test(entry.name)) {
|
||||
nonUiPatterns.push(`**/packages/tldraw/src/lib/${entry.name}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.name !== 'ui') {
|
||||
nonUiPatterns.push(`**/packages/tldraw/src/lib/${entry.name}/**/*.{js,jsx,ts,tsx}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const tldrawUiDir = path.join(tldrawLibDir, 'ui')
|
||||
const tldrawUiEntries = (await readdir(tldrawUiDir, { withFileTypes: true })).sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
)
|
||||
for (const uiEntry of tldrawUiEntries.filter((uiEntry) => !uiEntry.name.startsWith('.'))) {
|
||||
const uiPattern = uiEntry.isDirectory()
|
||||
? `**/packages/tldraw/src/lib/ui/${uiEntry.name}/**/*.{js,jsx,ts,tsx}`
|
||||
: /\.(ts|tsx|js|jsx)$/.test(uiEntry.name)
|
||||
? `**/packages/tldraw/src/lib/ui/${uiEntry.name}`
|
||||
: null
|
||||
|
||||
if (!uiPattern) continue
|
||||
if (uiEntry.name === 'components') {
|
||||
uiComponentsPattern = uiPattern
|
||||
} else if (uiEntry.name === 'context') {
|
||||
uiContextPattern = uiPattern
|
||||
} else {
|
||||
uiOtherPatterns.push(uiPattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const includePatternSets = [
|
||||
['**/packages/tldraw/src/index.ts', ...nonUiPatterns],
|
||||
...(uiComponentsPattern ? [['**/packages/tldraw/src/index.ts', uiComponentsPattern]] : []),
|
||||
...(uiContextPattern ? [['**/packages/tldraw/src/index.ts', uiContextPattern]] : []),
|
||||
...(uiOtherPatterns.length > 0
|
||||
? [['**/packages/tldraw/src/index.ts', ...uiOtherPatterns]]
|
||||
: []),
|
||||
]
|
||||
|
||||
return includePatternSets
|
||||
}
|
||||
|
||||
async function checkPackageForCircularDependencies(packageName: string, packageEntryPath: string) {
|
||||
const includePatternSets = await getIncludePatternSets(packageName)
|
||||
|
||||
const script = `
|
||||
import { build } from 'vite'
|
||||
import circularDependencyChecker from 'vite-plugin-circular-dependency'
|
||||
import { mkdirSync, readFileSync, rmSync } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const packageName = process.argv[1]
|
||||
const packageEntryPath = process.argv[2]
|
||||
const includePatternSets = JSON.parse(process.argv[3])
|
||||
const maxConcurrency = packageName === 'tldraw' ? 2 : 1
|
||||
|
||||
async function checkIncludeSet(includePatterns) {
|
||||
const outputFilePath = path.join(
|
||||
'.lazy',
|
||||
'circle-deps',
|
||||
packageName + '-' + Date.now() + '-' + Math.random().toString(36).slice(2) + '.json'
|
||||
)
|
||||
mkdirSync(path.dirname(outputFilePath), { recursive: true })
|
||||
|
||||
let cycleReport = {}
|
||||
try {
|
||||
await build({
|
||||
root: ${JSON.stringify(REPO_ROOT)},
|
||||
logLevel: 'error',
|
||||
plugins: [
|
||||
circularDependencyChecker({
|
||||
circleImportThrowErr: false,
|
||||
outputFilePath,
|
||||
include: includePatterns,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
write: false,
|
||||
emptyOutDir: false,
|
||||
minify: false,
|
||||
target: 'es2022',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
[packageName]: packageEntryPath,
|
||||
},
|
||||
onwarn(warning, warn) {
|
||||
if (warning.code === 'MODULE_LEVEL_DIRECTIVE') return
|
||||
warn(warning)
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
cycleReport = JSON.parse(readFileSync(outputFilePath, 'utf8'))
|
||||
} finally {
|
||||
rmSync(outputFilePath, { force: true })
|
||||
}
|
||||
|
||||
const cycleSignatures = Object.values(cycleReport)
|
||||
.flat()
|
||||
.map((chain) => chain.join(' -> '))
|
||||
|
||||
if (cycleSignatures.length > 0) {
|
||||
console.error(
|
||||
'Found circular dependencies:\\n' +
|
||||
cycleSignatures.map((signature) => ' ' + signature).join('\\n')
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const queue = [...includePatternSets]
|
||||
const workers = Array.from({ length: Math.min(maxConcurrency, queue.length) }, async () => {
|
||||
while (queue.length > 0) {
|
||||
const includePatterns = queue.shift()
|
||||
if (!includePatterns) return
|
||||
await checkIncludeSet(includePatterns)
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(workers)
|
||||
`
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = execFile(
|
||||
'node',
|
||||
[
|
||||
'--input-type=module',
|
||||
'-e',
|
||||
script,
|
||||
packageName,
|
||||
packageEntryPath,
|
||||
JSON.stringify(includePatternSets),
|
||||
],
|
||||
{ cwd: REPO_ROOT },
|
||||
(error) => {
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
}
|
||||
)
|
||||
|
||||
child.stdout?.on('data', (chunk) => process.stdout.write(chunk))
|
||||
child.stderr?.on('data', (chunk) => process.stderr.write(chunk))
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const entries = await getPackageEntries()
|
||||
const packageNames = Object.keys(entries)
|
||||
nicelog(`Checking for circular dependencies in packages (${packageNames.length}):`)
|
||||
nicelog(packageNames.join(', '))
|
||||
|
||||
const packageCheckResults = await Promise.allSettled(
|
||||
packageNames.map(async (packageName) => {
|
||||
const packageEntryPath = entries[packageName]
|
||||
nicelog(`Checking ${packageName}...`)
|
||||
await checkPackageForCircularDependencies(packageName, packageEntryPath)
|
||||
})
|
||||
)
|
||||
|
||||
const failedPackages = packageCheckResults.flatMap((result, index) =>
|
||||
result.status === 'rejected' ? [packageNames[index]] : []
|
||||
)
|
||||
|
||||
if (failedPackages.length > 0) {
|
||||
console.error(
|
||||
`Circular dependency checks failed for ${failedPackages.length} package(s): ${failedPackages.join(', ')}`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
nicelog(`No circular dependencies detected across ${packageNames.length} packages.`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
618
internal/scripts/check-packages.ts
Normal file
618
internal/scripts/check-packages.ts
Normal file
@@ -0,0 +1,618 @@
|
||||
import { existsSync } from 'fs'
|
||||
import path, { join, relative } from 'path'
|
||||
import kleur from 'kleur'
|
||||
import {
|
||||
REPO_ROOT,
|
||||
readFileIfExists,
|
||||
readJsonIfExists,
|
||||
writeCodeFile,
|
||||
writeJsonFile,
|
||||
writeStringFile,
|
||||
} from './lib/file'
|
||||
import { nicelog } from './lib/nicelog'
|
||||
import { PRODUCT_CONFIG_KEY } from './lib/types'
|
||||
import { Package, getAllWorkspacePackages, getRootPackage } from './lib/workspace'
|
||||
|
||||
const packagesWithoutTSConfigs: ReadonlySet<string> = new Set(['config'])
|
||||
|
||||
// all packages should have these scripts
|
||||
const expectedPackageJsonScriptsForAll = {
|
||||
lint: tsScript('lint.ts'),
|
||||
}
|
||||
|
||||
const expectedTestScripts = {
|
||||
test: () => 'yarn run -T vitest --passWithNoTests',
|
||||
'test-ci': () => 'yarn run -T vitest run --passWithNoTests',
|
||||
'test-coverage': () => 'yarn run -T vitest run --coverage --passWithNoTests',
|
||||
}
|
||||
|
||||
// packages (in packages/) should have these scripts
|
||||
const expectedPackageJsonScriptsForLibraries = {
|
||||
...expectedPackageJsonScriptsForAll,
|
||||
...expectedTestScripts,
|
||||
}
|
||||
|
||||
const expectedPackageJsonScriptsForApps = {
|
||||
...expectedPackageJsonScriptsForAll,
|
||||
...expectedTestScripts,
|
||||
}
|
||||
|
||||
// published packages should have these scripts
|
||||
const expectedPackageJsonScriptsForPublishedLibraries = {
|
||||
...expectedPackageJsonScriptsForLibraries,
|
||||
build: tsScript('build-package.ts'),
|
||||
'build-api': tsScript('build-api.ts'),
|
||||
prepack: tsScript('prepack.ts'),
|
||||
postpack: (packageDir: string) => scriptPath(packageDir, 'postpack.sh'),
|
||||
'pack-tarball': () => 'yarn pack',
|
||||
}
|
||||
|
||||
// individual packages can have different scripts than the above if needed
|
||||
const packageJsonScriptExceptions: Record<string, Record<string, () => string | undefined>> = {
|
||||
config: {
|
||||
lint: () => undefined,
|
||||
},
|
||||
tsconfig: {
|
||||
lint: () => undefined,
|
||||
},
|
||||
'@tldraw/monorepo': {
|
||||
lint: () => undefined,
|
||||
},
|
||||
'@tldraw/assets': {
|
||||
test: () => undefined,
|
||||
'test-ci': () => undefined,
|
||||
build: () => undefined,
|
||||
'build-api': () => undefined,
|
||||
prepack: () => undefined,
|
||||
postpack: () => undefined,
|
||||
},
|
||||
'create-tldraw': {
|
||||
build: () => './scripts/build.sh',
|
||||
'build-api': () => undefined,
|
||||
prepack: () => 'yarn build',
|
||||
postpack: () => undefined,
|
||||
},
|
||||
}
|
||||
|
||||
async function checkPackageJsonScripts({
|
||||
packages,
|
||||
fix,
|
||||
}: {
|
||||
packages: Package[]
|
||||
fix: boolean
|
||||
}): Promise<boolean> {
|
||||
const needsFix = new Set()
|
||||
|
||||
let errorCount = 0
|
||||
for (const { path: packageDir, relativePath, packageJson, name } of packages) {
|
||||
if (!packageJson.scripts) {
|
||||
packageJson.scripts = {}
|
||||
}
|
||||
const packageScripts = packageJson.scripts
|
||||
|
||||
let expected = relativePath.startsWith('packages/')
|
||||
? packageJson.private
|
||||
? expectedPackageJsonScriptsForLibraries
|
||||
: expectedPackageJsonScriptsForPublishedLibraries
|
||||
: relativePath.startsWith('apps/')
|
||||
? expectedPackageJsonScriptsForApps
|
||||
: expectedPackageJsonScriptsForAll
|
||||
|
||||
if (packageJsonScriptExceptions[name]) {
|
||||
expected = {
|
||||
...expected,
|
||||
...packageJsonScriptExceptions[name],
|
||||
}
|
||||
}
|
||||
|
||||
for (const [scriptName, getExpectedScript] of Object.entries(expected)) {
|
||||
const actualScript = packageScripts[scriptName]
|
||||
const expectedScript = getExpectedScript(packageDir)
|
||||
if (actualScript !== expectedScript) {
|
||||
nicelog(
|
||||
[
|
||||
'❌ ',
|
||||
kleur.red(`${name}: `),
|
||||
kleur.blue(`$ yarn ${scriptName}`),
|
||||
kleur.grey(' -> '),
|
||||
kleur.red(actualScript ?? '<missing>'),
|
||||
kleur.gray(' (expected: '),
|
||||
kleur.green(expectedScript),
|
||||
kleur.gray(')'),
|
||||
].join('')
|
||||
)
|
||||
packageScripts[scriptName] = expectedScript
|
||||
needsFix.add(name)
|
||||
errorCount++
|
||||
} else {
|
||||
nicelog(
|
||||
[
|
||||
'✅ ',
|
||||
kleur.green(`${name}: `),
|
||||
kleur.blue(`$ yarn ${scriptName}`),
|
||||
kleur.grey(' -> '),
|
||||
kleur.green(actualScript ?? '<missing>'),
|
||||
].join('')
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errorCount) {
|
||||
if (fix) {
|
||||
for (const { packageJson, name, relativePath } of packages) {
|
||||
if (needsFix.has(name)) {
|
||||
nicelog(kleur.yellow(`Fixing ${name}...`))
|
||||
await writeJsonFile(path.join(REPO_ROOT, relativePath, 'package.json'), packageJson)
|
||||
}
|
||||
}
|
||||
nicelog(kleur.yellow(`Fixed ${errorCount} errors`))
|
||||
return true
|
||||
} else {
|
||||
nicelog(kleur.red(`Found ${errorCount} errors`))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function checkTsConfigs({
|
||||
packages,
|
||||
fix,
|
||||
}: {
|
||||
fix: boolean
|
||||
packages: Package[]
|
||||
}): Promise<boolean> {
|
||||
let numErrors = 0
|
||||
|
||||
for (const workspace of packages) {
|
||||
const tsconfigPath = join(workspace.path, 'tsconfig.json')
|
||||
if (packagesWithoutTSConfigs.has(workspace.name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const tsconfig = (await readJsonIfExists(tsconfigPath)) as {
|
||||
references?: { path: string }[]
|
||||
}
|
||||
if (!tsconfig) {
|
||||
throw new Error('No tsconfig.json found at ' + tsconfigPath)
|
||||
}
|
||||
|
||||
const tldrawDeps = Object.keys({
|
||||
...workspace.packageJson.dependencies,
|
||||
...workspace.packageJson.devDependencies,
|
||||
}).filter((dep) => packages.some((p) => p.name === dep))
|
||||
|
||||
const fixedDeps = []
|
||||
const missingRefs = []
|
||||
const currentRefs = new Set<string>([...(tsconfig.references?.map((ref) => ref.path) ?? [])])
|
||||
for (const dep of tldrawDeps) {
|
||||
// construct the expected path to the dependency's tsconfig
|
||||
const matchingWorkspace = packages.find((p) => p.name === dep)
|
||||
if (!matchingWorkspace) {
|
||||
throw new Error(`No workspace found for ${dep}`)
|
||||
}
|
||||
const tsconfigReferencePath = relative(workspace.path, matchingWorkspace.path)
|
||||
fixedDeps.push({ path: tsconfigReferencePath })
|
||||
if (currentRefs.has(tsconfigReferencePath)) {
|
||||
currentRefs.delete(tsconfigReferencePath)
|
||||
} else {
|
||||
missingRefs.push(dep)
|
||||
}
|
||||
}
|
||||
|
||||
fixedDeps.sort((a, b) => a.path.localeCompare(b.path))
|
||||
|
||||
if (currentRefs.size > 0) {
|
||||
if (fix) {
|
||||
tsconfig.references = fixedDeps
|
||||
await writeJsonFile(tsconfigPath, tsconfig)
|
||||
} else {
|
||||
numErrors++
|
||||
nicelog(
|
||||
[
|
||||
'❌ ',
|
||||
kleur.red(`${workspace.name}: `),
|
||||
kleur.blue(relative(process.cwd(), tsconfigPath)),
|
||||
kleur.grey(' has unnecessary reference(s) to '),
|
||||
kleur.red([...currentRefs].join(', ')),
|
||||
].join('')
|
||||
)
|
||||
}
|
||||
}
|
||||
if (missingRefs.length) {
|
||||
if (fix) {
|
||||
tsconfig.references = fixedDeps
|
||||
await writeJsonFile(tsconfigPath, tsconfig)
|
||||
} else {
|
||||
numErrors++
|
||||
nicelog(
|
||||
[
|
||||
'❌ ',
|
||||
kleur.red(`${workspace.name}: `),
|
||||
kleur.blue(relative(process.cwd(), tsconfigPath)),
|
||||
kleur.grey(' is missing reference(s) to '),
|
||||
kleur.red(missingRefs.join(', ')),
|
||||
].join('')
|
||||
)
|
||||
nicelog('The references entry should look like this:')
|
||||
nicelog('"references": ' + JSON.stringify(fixedDeps, null, 2))
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRefs.size === 0 && missingRefs.length === 0) {
|
||||
nicelog(['✅ ', kleur.green(`${workspace.name}`)].join(''))
|
||||
}
|
||||
}
|
||||
if (numErrors > 0) {
|
||||
nicelog('Run `yarn check-tsconfigs --fix` to fix these problems')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function scriptPath(packageDir: string, scriptName: string) {
|
||||
return path.relative(packageDir, path.join(__dirname, scriptName))
|
||||
}
|
||||
|
||||
function tsScript(scriptName: string) {
|
||||
return (packageDir: string) => `yarn run -T tsx ${scriptPath(packageDir, scriptName)}`
|
||||
}
|
||||
|
||||
async function checkLibraryContents({
|
||||
fix,
|
||||
packages,
|
||||
}: {
|
||||
fix: boolean
|
||||
packages: Package[]
|
||||
}): Promise<boolean> {
|
||||
let errorCount = 0
|
||||
|
||||
for (const { packageJson, name, path } of packages) {
|
||||
if (packageJson.private) continue
|
||||
|
||||
const sourceFilePath = join(path, 'src', 'index.ts')
|
||||
const sourceFileContents = await readFileIfExists(sourceFilePath)
|
||||
if (!sourceFileContents) {
|
||||
nicelog(['⏩ ', kleur.blue(`${name}: `), 'skipped (no src/index.ts)'].join(''))
|
||||
continue
|
||||
}
|
||||
|
||||
const search = [
|
||||
'registerTldrawLibraryVersion(',
|
||||
'\t(globalThis as any).TLDRAW_LIBRARY_NAME,',
|
||||
'\t(globalThis as any).TLDRAW_LIBRARY_VERSION,',
|
||||
'\t(globalThis as any).TLDRAW_LIBRARY_MODULES',
|
||||
')',
|
||||
].join('\n')
|
||||
|
||||
if (sourceFileContents.includes(search)) {
|
||||
nicelog(['✅ ', kleur.green(name)].join(''))
|
||||
continue
|
||||
}
|
||||
|
||||
errorCount++
|
||||
if (fix) {
|
||||
const newSourceFileContents = [
|
||||
"import {registerTldrawLibraryVersion} from '@tldraw/utils'",
|
||||
sourceFileContents,
|
||||
'',
|
||||
'registerTldrawLibraryVersion(',
|
||||
'(globalThis as any).TLDRAW_LIBRARY_NAME,',
|
||||
'(globalThis as any).TLDRAW_LIBRARY_VERSION,',
|
||||
'(globalThis as any).TLDRAW_LIBRARY_NAME',
|
||||
')',
|
||||
].join('\n')
|
||||
|
||||
await writeCodeFile(null, 'typescript', sourceFilePath, newSourceFileContents)
|
||||
nicelog(
|
||||
[
|
||||
'⚠️ ',
|
||||
kleur.yellow(`${name}: `),
|
||||
'added call to ',
|
||||
kleur.blue('registerTldrawLibraryVersion'),
|
||||
].join('')
|
||||
)
|
||||
} else {
|
||||
nicelog(
|
||||
[
|
||||
'❌ ',
|
||||
kleur.red(`${name}: `),
|
||||
'missing call to ',
|
||||
kleur.blue('registerTldrawLibraryVersion'),
|
||||
].join('')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (errorCount) {
|
||||
if (fix) {
|
||||
nicelog(kleur.yellow(`Fixed ${errorCount} errors`))
|
||||
return true
|
||||
} else {
|
||||
nicelog(kleur.red(`Found ${errorCount} errors`))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const LICENSE_POINTER_CONTENTS =
|
||||
'This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)\n'
|
||||
|
||||
// The stable id convention is documented in internal/docs/product-stable-ids.md. Every published
|
||||
// package in packages/ must declare which commercial component it belongs to, and premium
|
||||
// components must name the license key flag that entitles them.
|
||||
async function checkProductMetadata({
|
||||
packages,
|
||||
fix,
|
||||
}: {
|
||||
packages: Package[]
|
||||
fix: boolean
|
||||
}): Promise<boolean> {
|
||||
let errorCount = 0
|
||||
const error = (name: string, message: string) => {
|
||||
errorCount++
|
||||
nicelog(['❌ ', kleur.red(`${name}: `), message].join(''))
|
||||
}
|
||||
|
||||
const licenseManagerSource = await readFileIfExists(
|
||||
join(REPO_ROOT, 'packages/editor/src/lib/license/LicenseManager.ts')
|
||||
)
|
||||
if (!licenseManagerSource) {
|
||||
throw new Error('Could not read LicenseManager.ts to validate license flags')
|
||||
}
|
||||
const knownLicenseFlags = new Set(
|
||||
[...licenseManagerSource.matchAll(/^\t(FEAT_[A-Z0-9_]+):/gm)].map((m) => m[1])
|
||||
)
|
||||
|
||||
const byStableId = new Map<string, { name: string; config: string }[]>()
|
||||
|
||||
for (const { packageJson, name, relativePath, path: packageDir } of packages) {
|
||||
if (!relativePath.startsWith('packages/') || packageJson.private) continue
|
||||
|
||||
// published packages that point at LICENSE.md must actually ship one, since npm only
|
||||
// includes license files that exist inside the package directory
|
||||
if (packageJson.license === 'SEE LICENSE IN LICENSE.md') {
|
||||
const licensePath = join(packageDir, 'LICENSE.md')
|
||||
if (!existsSync(licensePath)) {
|
||||
if (fix) {
|
||||
await writeStringFile(licensePath, LICENSE_POINTER_CONTENTS)
|
||||
nicelog(['⚠️ ', kleur.yellow(`${name}: `), 'added missing LICENSE.md'].join(''))
|
||||
} else {
|
||||
error(name, 'is missing LICENSE.md (run `yarn check-packages --fix`)')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const product = packageJson[PRODUCT_CONFIG_KEY]
|
||||
if (!product) {
|
||||
error(
|
||||
name,
|
||||
`is missing the "${PRODUCT_CONFIG_KEY}" field in package.json. ` +
|
||||
'See internal/docs/product-stable-ids.md for how to choose a stable id.'
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!/^tldraw:[a-z0-9-]+$/.test(product.stableId)) {
|
||||
error(name, `has invalid stableId "${product.stableId}" (expected tldraw:<kebab-case>)`)
|
||||
}
|
||||
if (product.premium && !product.licenseFlag) {
|
||||
error(name, 'is premium but has no licenseFlag')
|
||||
}
|
||||
if (!product.premium && product.licenseFlag) {
|
||||
error(name, 'has a licenseFlag but is not premium')
|
||||
}
|
||||
if (product.licenseFlag && !knownLicenseFlags.has(product.licenseFlag)) {
|
||||
error(
|
||||
name,
|
||||
`has licenseFlag "${product.licenseFlag}" which does not exist in LicenseManager FLAGS`
|
||||
)
|
||||
}
|
||||
|
||||
if (product.type === 'feature' && !product.parent) {
|
||||
error(name, 'is a feature but has no parent component')
|
||||
}
|
||||
if (product.type === 'product' && product.parent) {
|
||||
error(name, 'is a top-level product but declares a parent')
|
||||
}
|
||||
if (product.parent === product.stableId) {
|
||||
error(name, 'is its own parent')
|
||||
}
|
||||
|
||||
// all packages sharing a stable id must agree on the rest of the metadata, since they
|
||||
// describe the same commercial component
|
||||
const configKey = JSON.stringify([
|
||||
product.name,
|
||||
product.type,
|
||||
product.parent ?? null,
|
||||
product.premium,
|
||||
product.licenseFlag ?? null,
|
||||
])
|
||||
const others = byStableId.get(product.stableId) ?? []
|
||||
others.push({ name, config: configKey })
|
||||
byStableId.set(product.stableId, others)
|
||||
}
|
||||
|
||||
for (const [stableId, entries] of byStableId) {
|
||||
if (new Set(entries.map((e) => e.config)).size > 1) {
|
||||
error(
|
||||
stableId,
|
||||
`packages disagree on product metadata: ${entries.map((e) => e.name).join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// every parent must be a component that actually exists, or the order form would reference a
|
||||
// line item nothing defines
|
||||
for (const { packageJson, name, relativePath } of packages) {
|
||||
if (!relativePath.startsWith('packages/') || packageJson.private) continue
|
||||
const parent = packageJson[PRODUCT_CONFIG_KEY]?.parent
|
||||
if (parent && !byStableId.has(parent)) {
|
||||
error(name, `has parent "${parent}" which is not a known component`)
|
||||
}
|
||||
}
|
||||
|
||||
if (errorCount) {
|
||||
nicelog(kleur.red(`Found ${errorCount} errors`))
|
||||
return false
|
||||
}
|
||||
|
||||
nicelog(['✅ ', kleur.green('product metadata ok')].join(''))
|
||||
return true
|
||||
}
|
||||
|
||||
async function group<T>(name: string, cb: () => Promise<T>) {
|
||||
console.group(name)
|
||||
try {
|
||||
return await cb()
|
||||
} finally {
|
||||
console.groupEnd()
|
||||
console.log('')
|
||||
}
|
||||
}
|
||||
|
||||
// Every workspace uses the same version range for a dependency, so the lockfile resolves one
|
||||
// copy. To let a dependency diverge, list the workspaces that step off the shared range here:
|
||||
// they must agree with each other, and everything else must agree with everything else.
|
||||
const ALLOWED_VERSION_DIVERGENCE: Record<string, { workspaces: string[]; reason: string }> = {
|
||||
typescript: {
|
||||
workspaces: ['templates/', 'apps/mcp-app'],
|
||||
reason:
|
||||
"templates are independently published starters, and mcp-app's extract-editor-api.ts " +
|
||||
"needs the TS 5 compiler API that TS 7 doesn't export",
|
||||
},
|
||||
}
|
||||
|
||||
// Node 22.12 is the first version where `require()` of an ES module works unflagged, so
|
||||
// published packages can depend on ESM-only modules without breaking CommonJS consumers.
|
||||
// Earlier versions throw `ERR_REQUIRE_ESM`.
|
||||
const PUBLISHED_NODE_ENGINE = '>=22.12.0'
|
||||
|
||||
const DEPENDENCY_FIELDS = [
|
||||
'dependencies',
|
||||
'devDependencies',
|
||||
'optionalDependencies',
|
||||
'peerDependencies',
|
||||
] as const
|
||||
type DependencyField = (typeof DEPENDENCY_FIELDS)[number]
|
||||
|
||||
async function checkDependencyVersions({
|
||||
packages,
|
||||
fix,
|
||||
}: {
|
||||
packages: Package[]
|
||||
fix: boolean
|
||||
}): Promise<boolean> {
|
||||
let errorCount = 0
|
||||
const changed = new Set<Package>()
|
||||
const report = (name: string, message: string) => {
|
||||
errorCount++
|
||||
nicelog([fix ? '⚠️ ' : '❌ ', (fix ? kleur.yellow : kleur.red)(`${name}: `), message].join(''))
|
||||
}
|
||||
|
||||
const root = await getRootPackage()
|
||||
|
||||
// Peer ranges are deliberately wider than installed ranges, so they're only compared with
|
||||
// each other.
|
||||
const usages = new Map<string, { pkg: Package; field: DependencyField; range: string }[]>()
|
||||
for (const pkg of [root, ...packages]) {
|
||||
for (const field of DEPENDENCY_FIELDS) {
|
||||
for (const [dep, range] of Object.entries(pkg.packageJson[field] ?? {})) {
|
||||
const diverges = ALLOWED_VERSION_DIVERGENCE[dep]?.workspaces.some((prefix) =>
|
||||
pkg.relativePath.startsWith(prefix)
|
||||
)
|
||||
const kind = field === 'peerDependencies' ? 'peer' : 'installed'
|
||||
const key = `${dep}\0${kind}\0${diverges ? 'diverged' : 'shared'}`
|
||||
if (!usages.has(key)) usages.set(key, [])
|
||||
usages.get(key)!.push({ pkg, field, range })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, group] of usages) {
|
||||
const counts = new Map<string, number>()
|
||||
for (const { range } of group) counts.set(range, (counts.get(range) ?? 0) + 1)
|
||||
if (counts.size === 1) continue
|
||||
|
||||
const dep = key.split('\0')[0]
|
||||
const [expected] = [...counts].sort((a, b) => b[1] - a[1])[0]
|
||||
for (const { pkg, field, range } of group) {
|
||||
if (range === expected) continue
|
||||
report(pkg.name, `${field}.${dep} is ${range}, but other workspaces use ${expected}`)
|
||||
if (fix) {
|
||||
pkg.packageJson[field]![dep] = expected
|
||||
changed.add(pkg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const pkg of packages) {
|
||||
if ('packageManager' in pkg.packageJson) {
|
||||
report(pkg.name, 'only the root package.json may set packageManager')
|
||||
if (fix) {
|
||||
delete pkg.packageJson.packageManager
|
||||
changed.add(pkg)
|
||||
}
|
||||
}
|
||||
|
||||
if (!pkg.relativePath.startsWith('packages/')) continue
|
||||
const engines = pkg.packageJson.engines ?? {}
|
||||
if (engines.node !== PUBLISHED_NODE_ENGINE) {
|
||||
report(pkg.name, `engines.node must be ${PUBLISHED_NODE_ENGINE}`)
|
||||
if (fix) {
|
||||
pkg.packageJson.engines = { ...engines, node: PUBLISHED_NODE_ENGINE }
|
||||
changed.add(pkg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const pkg of changed) {
|
||||
await writeJsonFile(join(pkg.path, 'package.json'), pkg.packageJson)
|
||||
}
|
||||
|
||||
if (errorCount) {
|
||||
nicelog(
|
||||
fix
|
||||
? kleur.yellow(`Fixed ${errorCount} errors. Run \`yarn\` to update the lockfile.`)
|
||||
: kleur.red(`Found ${errorCount} errors. Run \`yarn check-packages --fix\` to fix them.`)
|
||||
)
|
||||
return fix
|
||||
}
|
||||
|
||||
nicelog(' ✅ dependency versions ok')
|
||||
return true
|
||||
}
|
||||
|
||||
async function main({ fix }: { fix: boolean }) {
|
||||
const packages = await getAllWorkspacePackages()
|
||||
|
||||
const scriptsOk = await group('Checking package.json scripts...', () =>
|
||||
checkPackageJsonScripts({ packages, fix })
|
||||
)
|
||||
const tsConfigsOk = await group('Checking tsconfig.json files...', () =>
|
||||
checkTsConfigs({ packages, fix })
|
||||
)
|
||||
const libsOk = await group('Checking library source files...', () =>
|
||||
checkLibraryContents({ packages, fix })
|
||||
)
|
||||
const productMetadataOk = await group('Checking product metadata...', () =>
|
||||
checkProductMetadata({ packages, fix })
|
||||
)
|
||||
|
||||
const dependencyVersionsOk = await group('Checking dependency versions...', () =>
|
||||
checkDependencyVersions({ packages, fix })
|
||||
)
|
||||
|
||||
if (!scriptsOk || !tsConfigsOk || !libsOk || !productMetadataOk || !dependencyVersionsOk) {
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main({
|
||||
fix: process.argv.includes('--fix'),
|
||||
})
|
||||
65
internal/scripts/check-worker-bundle.ts
Normal file
65
internal/scripts/check-worker-bundle.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { existsSync, readFileSync, rmSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import kleur from 'kleur'
|
||||
import minimist from 'minimist'
|
||||
import { exec } from './lib/exec'
|
||||
import { WORKER_EXTERNAL_DEPS } from './lib/worker-externals'
|
||||
|
||||
const args = minimist(process.argv.slice(2))
|
||||
const sizeLimit = Number(args['size-limit-bytes'])
|
||||
if (!isFinite(sizeLimit) || sizeLimit < 1) {
|
||||
console.error(
|
||||
'Invalid usage. Usage: yarn tsx check-worker-bundle.ts --size-limit-bytes <size> --entry <entry>'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const entry = args['entry']
|
||||
if (!existsSync(entry)) {
|
||||
console.error('Entry file does not exist:', entry)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const bundleMetaFileName = '.bundle-meta.json'
|
||||
|
||||
interface Meta {
|
||||
outputs: {
|
||||
[fileName: string]: {
|
||||
bytes: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkBundleSize() {
|
||||
rmSync(bundleMetaFileName, { force: true })
|
||||
|
||||
console.log('checking bundle size')
|
||||
await exec('yarn', [
|
||||
'esbuild',
|
||||
entry,
|
||||
'--bundle',
|
||||
'--outfile=/dev/null',
|
||||
'--minify',
|
||||
'--metafile=' + bundleMetaFileName,
|
||||
...WORKER_EXTERNAL_DEPS.map((dep) => '--external:' + dep),
|
||||
])
|
||||
|
||||
console.log(kleur.cyan().bold('Checking bundle size'), 'of', resolve(entry) + '\n')
|
||||
|
||||
const meta = JSON.parse(readFileSync(bundleMetaFileName).toString()) as Meta
|
||||
const totalSize = Object.values(meta.outputs).reduce((acc, output) => acc + output.bytes, 0)
|
||||
if (totalSize === 0) {
|
||||
throw new Error('Invalid bundle meta in ' + process.cwd() + '/' + bundleMetaFileName)
|
||||
}
|
||||
|
||||
if (totalSize > sizeLimit) {
|
||||
console.error(
|
||||
`${kleur.red().bold('ERROR')} Bundle size exceeds limit: ${totalSize} > ${sizeLimit}`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Bundle size is within limit: ${totalSize} < ${sizeLimit}`)
|
||||
}
|
||||
|
||||
checkBundleSize()
|
||||
41
internal/scripts/clean.sh
Executable file
41
internal/scripts/clean.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eux
|
||||
|
||||
yarn run lazy run clean || true
|
||||
|
||||
# a function called 'goodbye' that takes a string as an argument
|
||||
function goodbye() {
|
||||
rm -rf $1
|
||||
rm -rf */$1
|
||||
rm -rf */*/$1
|
||||
rm -rf */*/*/$1
|
||||
rm -rf */*/*/*/$1
|
||||
rm -rf */*/*/*/*/$1
|
||||
rm -rf **/$1
|
||||
}
|
||||
|
||||
rm -rf node_modules
|
||||
goodbye node_modules
|
||||
goodbye .turbo
|
||||
goodbye tmp-assets
|
||||
goodbye tsconfig.tsbuildinfo
|
||||
goodbye tsconfig.build.tsbuildinfo
|
||||
goodbye tsconfig.build.json
|
||||
goodbye .lazy
|
||||
goodbye dist-esm
|
||||
goodbye dist-cjs
|
||||
goodbye .tsbuild
|
||||
goodbye .tsbuild-pub
|
||||
goodbye .tsbuild-dev
|
||||
goodbye .tsbuild-api
|
||||
goodbye .next
|
||||
|
||||
rm -rf packages/*/api
|
||||
rm -rf {packages,apps}/*/*.tgz
|
||||
rm -rf {packages,apps}/vscode/extension/temp
|
||||
rm -rf {packages,apps}/vscode/extension/editor
|
||||
rm -rf apps/docs/content.json
|
||||
rm -rf apps/dotcom/client/e2e/.auth
|
||||
|
||||
npm i -g corepack
|
||||
yarn
|
||||
104
internal/scripts/cloudflare/dns-check.ts
Normal file
104
internal/scripts/cloudflare/dns-check.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { execFileSync } from 'child_process'
|
||||
import { makeEnv } from '../lib/makeEnv'
|
||||
import { nicelog } from '../lib/nicelog'
|
||||
|
||||
// Check DNS records in a zone: what's there already, and which pr-NNNN-* records are orphaned.
|
||||
// Read-only. Usage: CLOUDFLARE_API_TOKEN=xxx yarn tsx internal/scripts/cloudflare/dns-check.ts <zone_name>
|
||||
// Token needs "Zone: Read" + "DNS: Read"; gh CLI must be authed for tldraw/tldraw.
|
||||
|
||||
const env = makeEnv(['CLOUDFLARE_API_TOKEN'])
|
||||
|
||||
const [zoneName] = process.argv.slice(2)
|
||||
if (!zoneName) {
|
||||
nicelog('usage: dns-check.ts <zone_name>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const API = 'https://api.cloudflare.com/client/v4'
|
||||
|
||||
async function cfApi<T>(endpoint: string): Promise<T> {
|
||||
const res = await fetch(`${API}${endpoint}`, {
|
||||
headers: { Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}` },
|
||||
})
|
||||
if (!res.ok) throw new Error(`GET ${endpoint}: ${res.status} ${res.statusText}`)
|
||||
const data = (await res.json()) as { success: boolean; errors: unknown; result: T }
|
||||
if (!data.success) throw new Error(`GET ${endpoint}: ${JSON.stringify(data.errors)}`)
|
||||
return data.result
|
||||
}
|
||||
|
||||
const prStateCache = new Map<string, string>()
|
||||
function prState(pr: string): string {
|
||||
let state = prStateCache.get(pr)
|
||||
if (!state) {
|
||||
try {
|
||||
state = execFileSync('gh', ['api', `repos/tldraw/tldraw/pulls/${pr}`, '--jq', '.state'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim()
|
||||
} catch (err: any) {
|
||||
if (String(err.stderr).includes('HTTP 404')) {
|
||||
state = 'closed'
|
||||
} else {
|
||||
throw new Error(`gh api failed for PR #${pr}: ${err.stderr ?? err}`)
|
||||
}
|
||||
}
|
||||
prStateCache.set(pr, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
interface DnsRecord {
|
||||
type: string
|
||||
name: string
|
||||
content: string
|
||||
proxied: boolean
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const zones = await cfApi<{ id: string }[]>(`/zones?name=${zoneName}`)
|
||||
if (!zones[0]) throw new Error(`zone not found: ${zoneName}`)
|
||||
const zoneId = zones[0].id
|
||||
|
||||
const records: DnsRecord[] = []
|
||||
for (let page = 1; ; page++) {
|
||||
const result = await cfApi<DnsRecord[]>(
|
||||
`/zones/${zoneId}/dns_records?per_page=100&page=${page}`
|
||||
)
|
||||
records.push(...result)
|
||||
if (result.length < 100) break
|
||||
}
|
||||
|
||||
const fmt = (r: DnsRecord) =>
|
||||
`${r.type.padEnd(6)} ${r.name.padEnd(36)} ${String(r.content).slice(0, 50).padEnd(52)} proxied=${r.proxied}`
|
||||
|
||||
const isPreview = (r: DnsRecord) => /^pr-\d+-/.test(r.name)
|
||||
|
||||
nicelog('=== non-PR records (existing infra) ===')
|
||||
for (const r of records
|
||||
.filter((r) => !isPreview(r))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
nicelog(fmt(r))
|
||||
}
|
||||
|
||||
nicelog('\n=== PR preview records ===')
|
||||
let ok = 0
|
||||
let orphaned = 0
|
||||
for (const r of records.filter(isPreview)) {
|
||||
const pr = r.name.match(/^pr-(\d+)-/)![1]
|
||||
if (prState(pr) === 'closed') {
|
||||
nicelog(`ORPHAN (PR #${pr} closed) ${fmt(r)}`)
|
||||
orphaned++
|
||||
} else {
|
||||
nicelog(`ok (PR #${pr} open) ${fmt(r)}`)
|
||||
ok++
|
||||
}
|
||||
}
|
||||
|
||||
nicelog('\n=== summary ===')
|
||||
nicelog(`total records: ${records.length}`)
|
||||
nicelog(`pr records: ok=${ok} orphaned=${orphaned}`)
|
||||
const hasWildcard = records.some((r) => r.name === `*.${zoneName}`)
|
||||
nicelog(`wildcard *.${zoneName}: ${hasWildcard ? 'EXISTS' : 'not present'}`)
|
||||
}
|
||||
|
||||
main()
|
||||
142
internal/scripts/cloudflare/ssl-cleanup.ts
Normal file
142
internal/scripts/cloudflare/ssl-cleanup.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { execFileSync } from 'child_process'
|
||||
import { makeEnv } from '../lib/makeEnv'
|
||||
import { nicelog } from '../lib/nicelog'
|
||||
|
||||
// Delete orphaned per-PR advanced cert packs (pr-NNNN-* hostnames) for CLOSED PRs only.
|
||||
// Usage: CLOUDFLARE_API_TOKEN=xxx yarn tsx internal/scripts/cloudflare/ssl-cleanup.ts <zone_name> [--delete]
|
||||
// Default is dry-run. Token needs "Zone: Read" + "SSL and Certificates: Edit";
|
||||
// gh CLI must be authed for tldraw/tldraw.
|
||||
|
||||
const env = makeEnv(['CLOUDFLARE_API_TOKEN'])
|
||||
|
||||
const [zoneName, mode] = process.argv.slice(2)
|
||||
if (!zoneName || (mode && mode !== '--delete')) {
|
||||
nicelog('usage: ssl-cleanup.ts <zone_name> [--delete]')
|
||||
process.exit(1)
|
||||
}
|
||||
const doDelete = mode === '--delete'
|
||||
|
||||
const API = 'https://api.cloudflare.com/client/v4'
|
||||
|
||||
async function cfApi<T>(endpoint: string, options: RequestInit = {}, attempt = 0): Promise<T> {
|
||||
const res = await fetch(`${API}${endpoint}`, {
|
||||
...options,
|
||||
headers: { Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}` },
|
||||
})
|
||||
if (res.status === 429 && attempt < 3) {
|
||||
const waitSeconds = Number(res.headers.get('retry-after')) || 10
|
||||
nicelog(`rate limited, retrying in ${waitSeconds}s...`)
|
||||
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000))
|
||||
return cfApi(endpoint, options, attempt + 1)
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`${options.method ?? 'GET'} ${endpoint}: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
const data = (await res.json()) as { success: boolean; errors: unknown; result: T }
|
||||
if (!data.success) {
|
||||
throw new Error(`${options.method ?? 'GET'} ${endpoint}: ${JSON.stringify(data.errors)}`)
|
||||
}
|
||||
return data.result
|
||||
}
|
||||
|
||||
// Only an HTTP 404 (deleted PR) counts as closed; any other gh failure throws
|
||||
// so a broken token can't authorize deleting open-PR packs. Uses the gh CLI so
|
||||
// local runs reuse the developer's existing auth.
|
||||
const prStateCache = new Map<string, string>()
|
||||
function prState(pr: string): string {
|
||||
let state = prStateCache.get(pr)
|
||||
if (!state) {
|
||||
try {
|
||||
state = execFileSync('gh', ['api', `repos/tldraw/tldraw/pulls/${pr}`, '--jq', '.state'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim()
|
||||
} catch (err: any) {
|
||||
if (String(err.stderr).includes('HTTP 404')) {
|
||||
state = 'closed'
|
||||
} else {
|
||||
throw new Error(`gh api failed for PR #${pr}: ${err.stderr ?? err}`)
|
||||
}
|
||||
}
|
||||
prStateCache.set(pr, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
interface CertPack {
|
||||
id: string
|
||||
type: string
|
||||
status: string
|
||||
hosts: string[]
|
||||
}
|
||||
|
||||
function isPreviewPack(p: CertPack) {
|
||||
// status=all also returns packs that are already deleted/pending deletion, skip those
|
||||
return (
|
||||
p.type === 'advanced' &&
|
||||
p.status !== 'deleted' &&
|
||||
p.status !== 'pending_deletion' &&
|
||||
p.hosts.some((h) => /^pr-\d+-/.test(h))
|
||||
)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const zones = await cfApi<{ id: string }[]>(`/zones?name=${zoneName}`)
|
||||
if (!zones[0]) throw new Error(`zone not found: ${zoneName}`)
|
||||
const zoneId = zones[0].id
|
||||
|
||||
let deleted = 0
|
||||
let kept = 0
|
||||
let failed = 0
|
||||
|
||||
// Process one page at a time instead of fetching everything up front, so an
|
||||
// abort partway through (rate limit, network) loses nothing already deleted
|
||||
// and a rerun picks up where it left off. Deleting shifts later items into
|
||||
// the current page, so after a page with deletions we refetch the same page
|
||||
// (skipping packs we've already handled) and only advance once it yields
|
||||
// nothing new. The endpoint caps per_page at 50.
|
||||
const processed = new Set<string>()
|
||||
let page = 1
|
||||
while (true) {
|
||||
const result = await cfApi<CertPack[]>(
|
||||
`/zones/${zoneId}/ssl/certificate_packs?status=all&per_page=50&page=${page}`
|
||||
)
|
||||
let deletedThisPage = 0
|
||||
for (const pack of result.filter(isPreviewPack)) {
|
||||
if (processed.has(pack.id)) continue
|
||||
processed.add(pack.id)
|
||||
const pr = pack.hosts.find((h) => /^pr-\d+-/.test(h))!.match(/^pr-(\d+)-/)![1]
|
||||
const hosts = pack.hosts.join(',')
|
||||
if (prState(pr) !== 'closed') {
|
||||
nicelog(`keeping (PR #${pr} open) ${hosts}`)
|
||||
kept++
|
||||
continue
|
||||
}
|
||||
if (doDelete) {
|
||||
try {
|
||||
await cfApi(`/zones/${zoneId}/ssl/certificate_packs/${pack.id}`, { method: 'DELETE' })
|
||||
nicelog(`deleted (PR #${pr} closed) ${pack.id} ${hosts}`)
|
||||
deleted++
|
||||
deletedThisPage++
|
||||
} catch (err) {
|
||||
nicelog(`FAILED to delete ${pack.id} ${hosts}: ${err}`)
|
||||
failed++
|
||||
}
|
||||
} else {
|
||||
nicelog(`would delete (PR #${pr} closed) ${pack.id} ${hosts}`)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
if (result.length < 50) break
|
||||
if (deletedThisPage === 0) page++
|
||||
}
|
||||
|
||||
nicelog('---')
|
||||
nicelog(
|
||||
`${deleted} closed-PR packs ${doDelete ? 'deleted' : 'to delete'}, ${kept} open-PR packs kept, ${failed} failed`
|
||||
)
|
||||
if (!doDelete) nicelog('(dry run — rerun with --delete to remove)')
|
||||
if (failed > 0) process.exit(1)
|
||||
}
|
||||
|
||||
main()
|
||||
220
internal/scripts/create-docs-reference.ts
Normal file
220
internal/scripts/create-docs-reference.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Creates a reference document for Pandoc with custom styling:
|
||||
* - Times New Roman for all prose
|
||||
* - Consolas for all code
|
||||
* - Tighter margins and spacing for density
|
||||
*/
|
||||
|
||||
import { writeFileSync } from 'fs'
|
||||
import { dirname, resolve } from 'path'
|
||||
import {
|
||||
convertInchesToTwip,
|
||||
Document,
|
||||
HeadingLevel,
|
||||
Packer,
|
||||
PageOrientation,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
} from 'docx'
|
||||
import { nicelog } from './lib/nicelog'
|
||||
|
||||
const DOCS_DIR = resolve(dirname(dirname(dirname(__filename))), 'documentation')
|
||||
const REFERENCE_FILE = resolve(DOCS_DIR, 'reference.docx')
|
||||
|
||||
// Font settings
|
||||
const BODY_FONT = 'Times New Roman'
|
||||
const CODE_FONT = 'Consolas'
|
||||
const BODY_SIZE = 22 // 11pt in half-points
|
||||
const CODE_SIZE = 20 // 10pt in half-points
|
||||
const HEADING1_SIZE = 32 // 16pt
|
||||
const HEADING2_SIZE = 28 // 14pt
|
||||
const HEADING3_SIZE = 24 // 12pt
|
||||
|
||||
async function main() {
|
||||
nicelog('📄 Creating reference document for Pandoc...')
|
||||
|
||||
const doc = new Document({
|
||||
styles: {
|
||||
default: {
|
||||
document: {
|
||||
run: {
|
||||
font: BODY_FONT,
|
||||
size: BODY_SIZE,
|
||||
},
|
||||
paragraph: {
|
||||
spacing: {
|
||||
after: 120, // 6pt after paragraphs
|
||||
line: 240, // Single line spacing (240 twips = 1 line)
|
||||
},
|
||||
},
|
||||
},
|
||||
heading1: {
|
||||
run: {
|
||||
font: BODY_FONT,
|
||||
size: HEADING1_SIZE,
|
||||
bold: true,
|
||||
},
|
||||
paragraph: {
|
||||
spacing: {
|
||||
before: 240,
|
||||
after: 120,
|
||||
},
|
||||
},
|
||||
},
|
||||
heading2: {
|
||||
run: {
|
||||
font: BODY_FONT,
|
||||
size: HEADING2_SIZE,
|
||||
bold: true,
|
||||
},
|
||||
paragraph: {
|
||||
spacing: {
|
||||
before: 200,
|
||||
after: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
heading3: {
|
||||
run: {
|
||||
font: BODY_FONT,
|
||||
size: HEADING3_SIZE,
|
||||
bold: true,
|
||||
},
|
||||
paragraph: {
|
||||
spacing: {
|
||||
before: 160,
|
||||
after: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
heading4: {
|
||||
run: {
|
||||
font: BODY_FONT,
|
||||
size: BODY_SIZE,
|
||||
bold: true,
|
||||
},
|
||||
paragraph: {
|
||||
spacing: {
|
||||
before: 120,
|
||||
after: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
paragraphStyles: [
|
||||
{
|
||||
id: 'Normal',
|
||||
name: 'Normal',
|
||||
run: {
|
||||
font: BODY_FONT,
|
||||
size: BODY_SIZE,
|
||||
},
|
||||
paragraph: {
|
||||
spacing: {
|
||||
after: 120,
|
||||
line: 240,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'SourceCode',
|
||||
name: 'Source Code',
|
||||
basedOn: 'Normal',
|
||||
run: {
|
||||
font: CODE_FONT,
|
||||
size: CODE_SIZE,
|
||||
},
|
||||
paragraph: {
|
||||
spacing: {
|
||||
after: 0,
|
||||
line: 220,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'VerbatimChar',
|
||||
name: 'Verbatim Char',
|
||||
basedOn: 'Normal',
|
||||
run: {
|
||||
font: CODE_FONT,
|
||||
size: CODE_SIZE,
|
||||
},
|
||||
},
|
||||
],
|
||||
characterStyles: [
|
||||
{
|
||||
id: 'VerbatimChar',
|
||||
name: 'Verbatim Char',
|
||||
run: {
|
||||
font: CODE_FONT,
|
||||
size: CODE_SIZE,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
properties: {
|
||||
page: {
|
||||
margin: {
|
||||
top: convertInchesToTwip(0.75),
|
||||
right: convertInchesToTwip(0.75),
|
||||
bottom: convertInchesToTwip(0.75),
|
||||
left: convertInchesToTwip(0.75),
|
||||
},
|
||||
size: {
|
||||
orientation: PageOrientation.PORTRAIT,
|
||||
},
|
||||
},
|
||||
},
|
||||
children: [
|
||||
// Sample content to establish styles
|
||||
new Paragraph({
|
||||
text: 'Heading 1',
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
new Paragraph({
|
||||
text: 'Heading 2',
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
}),
|
||||
new Paragraph({
|
||||
text: 'Heading 3',
|
||||
heading: HeadingLevel.HEADING_3,
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: 'Normal body text in Times New Roman.',
|
||||
font: BODY_FONT,
|
||||
size: BODY_SIZE,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: 'const code = "Consolas font";',
|
||||
font: CODE_FONT,
|
||||
size: CODE_SIZE,
|
||||
}),
|
||||
],
|
||||
style: 'SourceCode',
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const buffer = await Packer.toBuffer(doc)
|
||||
writeFileSync(REFERENCE_FILE, buffer)
|
||||
|
||||
nicelog(`✅ Created: ${REFERENCE_FILE}`)
|
||||
nicelog(' Use with: pandoc --reference-doc=documentation/reference.docx')
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
nicelog(`❌ Failed: ${error}`)
|
||||
process.exit(1)
|
||||
})
|
||||
110
internal/scripts/deploy-analytics.ts
Normal file
110
internal/scripts/deploy-analytics.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import assert from 'assert'
|
||||
import { readFileSync } from 'fs'
|
||||
import path, { join } from 'path'
|
||||
import toml from 'toml'
|
||||
import {
|
||||
createGithubDeployment,
|
||||
getDeployInfo,
|
||||
setWranglerPreviewConfig,
|
||||
wranglerDeploy,
|
||||
} from './lib/deploy'
|
||||
import { Discord } from './lib/discord'
|
||||
import { REPO_ROOT } from './lib/file'
|
||||
import { makeEnv } from './lib/makeEnv'
|
||||
import { nicelog } from './lib/nicelog'
|
||||
|
||||
const workerDir = path.relative(process.cwd(), path.resolve(REPO_ROOT, './apps/analytics-worker'))
|
||||
|
||||
// Do not use `process.env` directly in this script. Add your variable to `makeEnv` and use it via
|
||||
// `env` instead. This makes sure that all required env vars are present.
|
||||
const env = makeEnv([
|
||||
'CLOUDFLARE_ACCOUNT_ID',
|
||||
'CLOUDFLARE_API_TOKEN',
|
||||
'DISCORD_DEPLOY_WEBHOOK_URL',
|
||||
'RELEASE_COMMIT_HASH',
|
||||
'TLDRAW_ENV',
|
||||
'GH_TOKEN',
|
||||
])
|
||||
|
||||
const discord = new Discord({
|
||||
webhookUrl: env.DISCORD_DEPLOY_WEBHOOK_URL,
|
||||
shouldNotify: env.TLDRAW_ENV === 'production',
|
||||
totalSteps: 2,
|
||||
messagePrefix: '[ANALYTICS]',
|
||||
secretValues: Object.values(env),
|
||||
})
|
||||
|
||||
const { previewId, sha } = getDeployInfo()
|
||||
|
||||
async function main() {
|
||||
assert(
|
||||
env.TLDRAW_ENV === 'staging' || env.TLDRAW_ENV === 'production' || env.TLDRAW_ENV === 'preview',
|
||||
'TLDRAW_ENV must be staging or production or preview'
|
||||
)
|
||||
|
||||
await discord.message(`--- **${env.TLDRAW_ENV} analytics worker deploy pre-flight** ---`)
|
||||
|
||||
await discord.step('cloudflare deploy dry run', async () => {
|
||||
await deployAnalyticsWorker({ dryRun: true })
|
||||
})
|
||||
|
||||
// --- point of no return! do the deploy for real --- //
|
||||
|
||||
await discord.message(`--- **pre-flight complete, starting real analytics worker deploy** ---`)
|
||||
|
||||
await discord.step('deploying analytics worker to cloudflare', async () => {
|
||||
await deployAnalyticsWorker({ dryRun: false })
|
||||
})
|
||||
|
||||
// Get the worker name from wrangler.toml
|
||||
const wranglerConfig = toml.parse(readFileSync(join(workerDir, 'wrangler.toml')).toString())
|
||||
const workerName = wranglerConfig?.env?.[env.TLDRAW_ENV]?.name || wranglerConfig?.name
|
||||
if (!workerName) {
|
||||
throw new Error('Could not find worker name in wrangler.toml')
|
||||
}
|
||||
|
||||
// Analytics worker uses default workers.dev domain
|
||||
const deploymentUrl = `https://${workerName}.${env.CLOUDFLARE_ACCOUNT_ID}.workers.dev`
|
||||
|
||||
nicelog('Creating deployment for', deploymentUrl)
|
||||
await createGithubDeployment(env, {
|
||||
app: 'analytics-worker',
|
||||
deploymentUrl,
|
||||
sha,
|
||||
})
|
||||
|
||||
await discord.message(`**Deploy complete!**`)
|
||||
}
|
||||
|
||||
let didUpdateAnalyticsWorker = false
|
||||
async function deployAnalyticsWorker({ dryRun }: { dryRun: boolean }) {
|
||||
const workerId = `${previewId ?? env.TLDRAW_ENV}-tldraw-consent`
|
||||
if (previewId && !didUpdateAnalyticsWorker) {
|
||||
await setWranglerPreviewConfig(workerDir, {
|
||||
name: workerId,
|
||||
routeHostname: `${previewId}-consent.tldraw.xyz`,
|
||||
})
|
||||
didUpdateAnalyticsWorker = true
|
||||
}
|
||||
|
||||
await wranglerDeploy({
|
||||
location: workerDir,
|
||||
dryRun,
|
||||
env: env.TLDRAW_ENV,
|
||||
vars: {
|
||||
WORKER_NAME: workerId,
|
||||
TLDRAW_ENV: env.TLDRAW_ENV,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
// don't notify discord on preview builds
|
||||
if (env.TLDRAW_ENV !== 'preview') {
|
||||
await discord.message(`${Discord.AT_TEAM_MENTION} Deploy failed: ${err.stack}`, {
|
||||
always: true,
|
||||
})
|
||||
}
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
123
internal/scripts/deploy-bemo.ts
Normal file
123
internal/scripts/deploy-bemo.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import assert from 'assert'
|
||||
import { readFileSync } from 'fs'
|
||||
import path, { join } from 'path'
|
||||
import toml from 'toml'
|
||||
import {
|
||||
createGithubDeployment,
|
||||
getDeployInfo,
|
||||
setWranglerPreviewConfig,
|
||||
wranglerDeploy,
|
||||
} from './lib/deploy'
|
||||
import { Discord } from './lib/discord'
|
||||
import { exec } from './lib/exec'
|
||||
import { REPO_ROOT } from './lib/file'
|
||||
import { makeEnv } from './lib/makeEnv'
|
||||
import { nicelog } from './lib/nicelog'
|
||||
|
||||
const workerDir = path.relative(process.cwd(), path.resolve(REPO_ROOT, './apps/bemo-worker'))
|
||||
|
||||
// Do not use `process.env` directly in this script. Add your variable to `makeEnv` and use it via
|
||||
// `env` instead. This makes sure that all required env vars are present.
|
||||
const env = makeEnv([
|
||||
'CLOUDFLARE_ACCOUNT_ID',
|
||||
'CLOUDFLARE_API_TOKEN',
|
||||
'DISCORD_DEPLOY_WEBHOOK_URL',
|
||||
'RELEASE_COMMIT_HASH',
|
||||
'TLDRAW_ENV',
|
||||
'GH_TOKEN',
|
||||
'SENTRY_AUTH_TOKEN',
|
||||
'SENTRY_BEMO_WORKER_DSN',
|
||||
])
|
||||
|
||||
const discord = new Discord({
|
||||
webhookUrl: env.DISCORD_DEPLOY_WEBHOOK_URL,
|
||||
shouldNotify: env.TLDRAW_ENV === 'production',
|
||||
totalSteps: 3,
|
||||
secretValues: Object.values(env),
|
||||
})
|
||||
|
||||
const { previewId, sha } = getDeployInfo()
|
||||
|
||||
async function main() {
|
||||
assert(
|
||||
env.TLDRAW_ENV === 'staging' || env.TLDRAW_ENV === 'production' || env.TLDRAW_ENV === 'preview',
|
||||
'TLDRAW_ENV must be staging or production or preview'
|
||||
)
|
||||
|
||||
await discord.message(`--- **${env.TLDRAW_ENV} bemo deploy pre-flight** ---`)
|
||||
|
||||
await discord.step('setting up deploy', async () => {
|
||||
await exec('yarn', ['lazy', 'prebuild'])
|
||||
})
|
||||
|
||||
await discord.step('cloudflare deploy dry run', async () => {
|
||||
await deployBemoWorker({ dryRun: true })
|
||||
})
|
||||
|
||||
// --- point of no return! do the deploy for real --- //
|
||||
|
||||
await discord.message(`--- **pre-flight complete, starting real bemo deploy** ---`)
|
||||
|
||||
// 2. deploy the cloudflare workers:
|
||||
await discord.step('deploying bemo-worker to cloudflare', async () => {
|
||||
await deployBemoWorker({ dryRun: false })
|
||||
})
|
||||
|
||||
// the deploy hostname comes from the env's route pattern in wrangler.toml
|
||||
const routePattern = toml.parse(readFileSync(join(workerDir, 'wrangler.toml')).toString())?.env[
|
||||
env.TLDRAW_ENV
|
||||
]?.routes?.[0]?.pattern
|
||||
if (!routePattern) {
|
||||
throw new Error('Could not find the route pattern in wrangler.toml')
|
||||
}
|
||||
|
||||
// preview route patterns carry a path glob ("host/*"); the deployment URL wants just the host
|
||||
const deploymentUrl = `https://${routePattern.split('/')[0]}`
|
||||
|
||||
nicelog('Creating deployment for', deploymentUrl)
|
||||
await createGithubDeployment(env, {
|
||||
app: 'bemo',
|
||||
deploymentUrl,
|
||||
sha,
|
||||
})
|
||||
|
||||
await discord.message(`**Deploy complete!**`)
|
||||
}
|
||||
|
||||
let didUpdateBemoWorker = false
|
||||
async function deployBemoWorker({ dryRun }: { dryRun: boolean }) {
|
||||
const workerId = `${previewId ?? env.TLDRAW_ENV}-bemo`
|
||||
if (previewId && !didUpdateBemoWorker) {
|
||||
await setWranglerPreviewConfig(workerDir, {
|
||||
name: workerId,
|
||||
routeHostname: `${previewId}-demo.tldraw.xyz`,
|
||||
})
|
||||
didUpdateBemoWorker = true
|
||||
}
|
||||
|
||||
await wranglerDeploy({
|
||||
location: workerDir,
|
||||
dryRun,
|
||||
env: env.TLDRAW_ENV,
|
||||
vars: {
|
||||
WORKER_NAME: workerId,
|
||||
TLDRAW_ENV: env.TLDRAW_ENV,
|
||||
SENTRY_DSN: env.SENTRY_BEMO_WORKER_DSN,
|
||||
},
|
||||
sentry: {
|
||||
authToken: env.SENTRY_AUTH_TOKEN,
|
||||
project: 'bemo-worker',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
// don't notify discord on preview builds
|
||||
if (env.TLDRAW_ENV !== 'preview') {
|
||||
await discord.message(`${Discord.AT_TEAM_MENTION} Deploy failed: ${err.stack}`, {
|
||||
always: true,
|
||||
})
|
||||
}
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
1093
internal/scripts/deploy-dotcom.ts
Normal file
1093
internal/scripts/deploy-dotcom.ts
Normal file
File diff suppressed because it is too large
Load Diff
100
internal/scripts/deploy-template.ts
Normal file
100
internal/scripts/deploy-template.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { readdir } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { exec } from './lib/exec'
|
||||
import { readJsonIfExists, REPO_ROOT } from './lib/file'
|
||||
import { makeEnv } from './lib/makeEnv'
|
||||
import { nicelog } from './lib/nicelog'
|
||||
import { EXPORT_CONFIG_KEY, PackageJson } from './lib/types'
|
||||
|
||||
const TEMPLATE_DIR = join(REPO_ROOT, 'templates')
|
||||
|
||||
/**
|
||||
* Like `makeEnv`, but also rejects empty values. Github expands an unconfigured secret or variable
|
||||
* to the empty string rather than leaving it unset, so `makeEnv` alone would let a missing license
|
||||
* key through and we'd deploy unlicensed demos on a green CI run — the exact thing this script
|
||||
* exists to prevent.
|
||||
*/
|
||||
function requireEnv<const Keys extends readonly string[]>(keys: Keys) {
|
||||
const env = makeEnv(keys)
|
||||
const emptyVars = keys.filter((key) => env[key as keyof typeof env] === '')
|
||||
if (emptyVars.length > 0) {
|
||||
throw new Error(`Empty environment variables: ${emptyVars.join(', ')}`)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// The license key for the demos deployed from here. Don't set one in the cloudflare dashboard as
|
||||
// well: a key configured there is invisible to this repo, so nothing catches it silently expiring.
|
||||
//
|
||||
// The templates themselves never reference it. tldraw's LicenseProvider picks it up from the build
|
||||
// environment (see getLicenseKeyFromEnv in packages/editor/src/lib/license/LicenseProvider.tsx), so
|
||||
// the exported public templates stay free of our key.
|
||||
const env = requireEnv(['TEMPLATES_TLDRAW_LICENSE_KEY'])
|
||||
|
||||
// every template deployed from here builds with vite. A next.js template would need
|
||||
// NEXT_PUBLIC_TLDRAW_LICENSE_KEY instead.
|
||||
const licenseEnv = { VITE_TLDRAW_LICENSE_KEY: env.TEMPLATES_TLDRAW_LICENSE_KEY }
|
||||
|
||||
async function main() {
|
||||
const templateName = process.argv[2]
|
||||
if (!templateName) {
|
||||
nicelog('Usage: tsx deploy-template.ts <template-name>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const possibleTemplates = (await readdir(TEMPLATE_DIR, { withFileTypes: true }))
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name)
|
||||
|
||||
if (!possibleTemplates.includes(templateName)) {
|
||||
nicelog(`Template "${templateName}" not found.`)
|
||||
nicelog(`Possible templates: ${possibleTemplates.join(', ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const templateDir = join(TEMPLATE_DIR, templateName)
|
||||
const packageJsonRaw = await readJsonIfExists(join(templateDir, 'package.json'))
|
||||
if (!packageJsonRaw) {
|
||||
nicelog('No package.json found')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const packageJson = PackageJson.validate(packageJsonRaw)
|
||||
const deployConfig = packageJson[EXPORT_CONFIG_KEY]?.deploy
|
||||
if (!deployConfig) {
|
||||
nicelog(`Template "${templateName}" has no deploy config. Skipping.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await deployToCloudflare(templateName, templateDir)
|
||||
|
||||
nicelog(`Deployed ${templateName} to https://${deployConfig.host}`)
|
||||
}
|
||||
|
||||
async function deployToCloudflare(templateName: string, templateDir: string) {
|
||||
const cloudflareEnv = requireEnv(['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN'])
|
||||
|
||||
nicelog(`Building ${templateName}...`)
|
||||
await exec('yarn', ['run', 'build'], {
|
||||
pwd: templateDir,
|
||||
env: { ...licenseEnv, NODE_ENV: 'production' },
|
||||
})
|
||||
|
||||
// The route (including the custom domain) comes from the template's own wrangler.toml, which
|
||||
// ships to users too — so the deploy target is described in one place rather than here.
|
||||
nicelog(`Deploying ${templateName} to cloudflare...`)
|
||||
await exec('yarn', ['wrangler', 'deploy'], {
|
||||
pwd: templateDir,
|
||||
env: {
|
||||
...cloudflareEnv,
|
||||
NODE_ENV: 'production',
|
||||
// wrangler needs CI=1 set to prevent it from trying to do interactive prompts
|
||||
CI: '1',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
45
internal/scripts/dev-template.sh
Executable file
45
internal/scripts/dev-template.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
|
||||
template_name="$1"
|
||||
shift || true
|
||||
vite_args=("$@")
|
||||
workspace_root="$(git rev-parse --show-toplevel)"
|
||||
|
||||
if [ -z "$template_name" ]; then
|
||||
echo "Usage: yarn dev-template <template_name>"
|
||||
echo "Available templates: $(ls templates | awk 'ORS=", "' | sed 's/, $//')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "templates/$template_name" ]; then
|
||||
echo "Template $template_name does not exist"
|
||||
echo "Available templates: $(ls templates | awk 'ORS=", "' | sed 's/, $//')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ${#vite_args[@]} -eq 0 ]; then
|
||||
LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter="templates/$template_name" --filter='packages/tldraw' --filter='apps/bemo-worker'
|
||||
exit $?
|
||||
fi
|
||||
|
||||
# Running vite directly skips lazy's `dev` prerequisites. Only refresh-assets feeds templates, so
|
||||
# run it here first (usually a cache hit) and bail on failure like the normal `lazy run dev` path.
|
||||
LAZYREPO_PRETTY_OUTPUT=0 lazy run refresh-assets || exit 1
|
||||
|
||||
# lazy doesn't forward extra args, so run vite directly in the template below. Run the shared deps
|
||||
# in their own process group (set -m) so cleanup can kill lazy and its watchers, which lazy itself
|
||||
# won't (it installs no signal handlers, so killing just its pid would orphan them).
|
||||
set -m
|
||||
LAZYREPO_PRETTY_OUTPUT=0 lazy run dev --filter='packages/tldraw' --filter='apps/bemo-worker' &
|
||||
lazy_pid=$!
|
||||
disown
|
||||
cleanup() {
|
||||
# negative pid signals the whole process group
|
||||
kill -- "-$lazy_pid" 2>/dev/null
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
cd "$workspace_root/templates/$template_name" || exit 1
|
||||
# Args are appended to the template's `dev` script, so they only reach vite for plain-vite templates.
|
||||
# For concurrently-wrapped or next.js templates they hit the wrapper/next instead.
|
||||
yarn dev "${vite_args[@]}"
|
||||
72
internal/scripts/dotcom/cleanup-test-files.ts
Normal file
72
internal/scripts/dotcom/cleanup-test-files.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3'
|
||||
import { Client } from 'pg'
|
||||
import { makeEnv } from '../lib/makeEnv'
|
||||
|
||||
const env = makeEnv([
|
||||
'CLOUDFLARE_ACCOUNT_ID',
|
||||
'R2_ACCESS_KEY_ID',
|
||||
'R2_ACCESS_KEY_SECRET',
|
||||
'SUPABASE_STAGING_DB_URL',
|
||||
'STAGING_OWNER_ID',
|
||||
])
|
||||
|
||||
const R2_URL = `https://${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`
|
||||
const R2_STAGING_BUCKET = 'rooms-preview'
|
||||
|
||||
const R2 = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: R2_URL,
|
||||
credentials: {
|
||||
accessKeyId: env.R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.R2_ACCESS_KEY_SECRET,
|
||||
},
|
||||
})
|
||||
|
||||
async function cleanupTestFiles() {
|
||||
const stagingClient = new Client({
|
||||
connectionString: env.SUPABASE_STAGING_DB_URL,
|
||||
})
|
||||
await stagingClient.connect()
|
||||
|
||||
try {
|
||||
const selectQuery = `SELECT id FROM file WHERE file."owningGroupId" = $1 AND id LIKE 'test_%'`
|
||||
const selectResult = await stagingClient.query(selectQuery, [env.STAGING_OWNER_ID])
|
||||
|
||||
const fileIds = selectResult.rows.map((row: any) => row.id)
|
||||
|
||||
for (const fileId of fileIds) {
|
||||
try {
|
||||
const deleteParams = {
|
||||
Bucket: R2_STAGING_BUCKET,
|
||||
Key: `app_rooms/${fileId}`,
|
||||
}
|
||||
|
||||
const deleteResponse = await R2.send(new DeleteObjectCommand(deleteParams))
|
||||
if (
|
||||
!deleteResponse.$metadata.httpStatusCode ||
|
||||
(deleteResponse.$metadata.httpStatusCode !== 200 &&
|
||||
deleteResponse.$metadata.httpStatusCode !== 204)
|
||||
) {
|
||||
console.error('Failed to delete file from R2 staging bucket')
|
||||
continue
|
||||
}
|
||||
|
||||
const deleteFileQuery = `DELETE FROM file WHERE id = $1`
|
||||
const deleteResult = await stagingClient.query(deleteFileQuery, [fileId])
|
||||
|
||||
if (deleteResult.rowCount === 0) {
|
||||
console.error('Failed to delete file from Supabase database - no rows affected')
|
||||
continue
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file', error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Successfully cleaned up ${fileIds.length} test files`)
|
||||
} finally {
|
||||
await stagingClient.end()
|
||||
}
|
||||
}
|
||||
|
||||
cleanupTestFiles().catch(console.error)
|
||||
182
internal/scripts/dotcom/copy-random-files-to-staging.ts
Normal file
182
internal/scripts/dotcom/copy-random-files-to-staging.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
|
||||
import type { TlaFile } from '@tldraw/dotcom-shared'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { Client } from 'pg'
|
||||
import { makeEnv } from '../lib/makeEnv'
|
||||
|
||||
const env = makeEnv([
|
||||
'CLOUDFLARE_ACCOUNT_ID',
|
||||
'R2_ACCESS_KEY_ID',
|
||||
'R2_ACCESS_KEY_SECRET',
|
||||
'SUPABASE_PRODUCTION_DB_URL',
|
||||
'SUPABASE_STAGING_DB_URL',
|
||||
'STAGING_OWNER_ID',
|
||||
])
|
||||
|
||||
const R2_URL = `https://${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`
|
||||
const R2_PRODUCTION_BUCKET = 'rooms'
|
||||
const R2_STAGING_BUCKET = 'rooms-preview'
|
||||
|
||||
const R2 = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: R2_URL,
|
||||
credentials: {
|
||||
accessKeyId: env.R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.R2_ACCESS_KEY_SECRET,
|
||||
},
|
||||
})
|
||||
|
||||
async function getRandomFileIds(n: number): Promise<string[]> {
|
||||
const client = new Client({
|
||||
connectionString: env.SUPABASE_PRODUCTION_DB_URL,
|
||||
})
|
||||
await client.connect()
|
||||
|
||||
const threeDays = 3 * 24 * 60 * 60 * 1000
|
||||
|
||||
const query = `
|
||||
SELECT * FROM file
|
||||
TABLESAMPLE BERNOULLI(30)
|
||||
WHERE "shared" = true
|
||||
AND ("updatedAt" - "createdAt") >= $1
|
||||
LIMIT $2;
|
||||
`
|
||||
|
||||
const { rows } = await client.query(query, [threeDays, n])
|
||||
await client.end()
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
console.log('No files found matching the criteria')
|
||||
return []
|
||||
}
|
||||
|
||||
return rows.map((r) => r.id)
|
||||
}
|
||||
|
||||
async function copyFilesToStaging(fileIds: string[]) {
|
||||
const copiedIds: string[] = []
|
||||
|
||||
// Create one staging client for all database operations
|
||||
const stagingClient = new Client({
|
||||
connectionString: env.SUPABASE_STAGING_DB_URL,
|
||||
})
|
||||
await stagingClient.connect()
|
||||
|
||||
try {
|
||||
for (const fileId of fileIds) {
|
||||
try {
|
||||
const getParams = {
|
||||
Bucket: R2_PRODUCTION_BUCKET,
|
||||
Key: `app_rooms/${fileId}`,
|
||||
}
|
||||
|
||||
const getResponse = await R2.send(new GetObjectCommand(getParams))
|
||||
|
||||
if (!getResponse.Body) {
|
||||
console.error('Failed to download file - no body')
|
||||
continue
|
||||
}
|
||||
|
||||
const fileContent = await getResponse.Body.transformToByteArray()
|
||||
|
||||
const newId = `test_${nanoid()}`
|
||||
|
||||
const putParams = {
|
||||
Bucket: R2_STAGING_BUCKET,
|
||||
Key: `app_rooms/${newId}`,
|
||||
Body: Buffer.from(fileContent),
|
||||
ContentType: 'application/octet-stream',
|
||||
}
|
||||
|
||||
const putResponse = await R2.send(new PutObjectCommand(putParams))
|
||||
if (!putResponse.$metadata.httpStatusCode || putResponse.$metadata.httpStatusCode !== 200) {
|
||||
console.error('Failed to upload file to staging bucket')
|
||||
continue
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const testFile: TlaFile = {
|
||||
id: newId,
|
||||
name: 'Test File',
|
||||
ownerName: 'Test User',
|
||||
thumbnail: '',
|
||||
shared: false,
|
||||
sharedLinkType: 'link',
|
||||
published: false,
|
||||
lastPublished: 0,
|
||||
publishedSlug: nanoid(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isEmpty: false,
|
||||
isDeleted: false,
|
||||
createSource: null,
|
||||
// the staging user's home workspace: home group id is the user's own id. Must be set —
|
||||
// file_owner_xor_check rejects a row with neither owner, and cleanup-test-files finds
|
||||
// these rows by owningGroupId.
|
||||
owningGroupId: env.STAGING_OWNER_ID,
|
||||
}
|
||||
|
||||
const insertQuery = `
|
||||
INSERT INTO file (id, name, "ownerName", thumbnail, shared, "sharedLinkType", published, "lastPublished", "publishedSlug", "createdAt", "updatedAt", "isEmpty", "isDeleted", "owningGroupId")
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
`
|
||||
await stagingClient.query(insertQuery, [
|
||||
testFile.id,
|
||||
testFile.name,
|
||||
testFile.ownerName,
|
||||
testFile.thumbnail,
|
||||
testFile.shared,
|
||||
testFile.sharedLinkType,
|
||||
testFile.published,
|
||||
testFile.lastPublished,
|
||||
testFile.publishedSlug,
|
||||
testFile.createdAt,
|
||||
testFile.updatedAt,
|
||||
testFile.isEmpty,
|
||||
testFile.isDeleted,
|
||||
testFile.owningGroupId,
|
||||
])
|
||||
|
||||
await stagingClient.query(
|
||||
`INSERT INTO group_file ("fileId", "groupId", "createdAt", "updatedAt", "index")
|
||||
VALUES ($1, $2, $3, $4, NULL)`,
|
||||
[testFile.id, testFile.owningGroupId, now, now]
|
||||
)
|
||||
|
||||
copiedIds.push(newId)
|
||||
} catch (error) {
|
||||
console.error('Error copying file:', error)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await stagingClient.end()
|
||||
}
|
||||
|
||||
console.log(`Successfully copied ${copiedIds.length} files to staging`)
|
||||
|
||||
if (copiedIds.length > 0) {
|
||||
const testFilesPath = join(
|
||||
__dirname,
|
||||
'../../../apps/dotcom/client/e2e/fixtures/test-files.json'
|
||||
)
|
||||
writeFileSync(testFilesPath, JSON.stringify(copiedIds, null, 2))
|
||||
console.log('Updated test files')
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const n = parseInt(process.env.STAGING_TEST_FILES_COUNT || '10')
|
||||
console.log(`Getting ${n} random file IDs...`)
|
||||
const fileIds = await getRandomFileIds(n)
|
||||
if (fileIds.length === 0) {
|
||||
console.log('No files found to copy')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('\nCopying files to staging...')
|
||||
await copyFilesToStaging(fileIds)
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
170
internal/scripts/export-template.ts
Normal file
170
internal/scripts/export-template.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { copyFile, mkdir, mkdtemp, readdir, readFile, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { dirname, join } from 'path'
|
||||
import { exec } from './lib/exec'
|
||||
import { readJsonIfExists, REPO_ROOT, writeCodeFile, writeJsonFile } from './lib/file'
|
||||
import { EXPORT_CONFIG_KEY, PackageJson, TsConfigJson } from './lib/types'
|
||||
|
||||
const TEMPLATE_DIR = join(REPO_ROOT, 'templates')
|
||||
|
||||
async function main() {
|
||||
const templateName = process.argv[2]
|
||||
if (!templateName) {
|
||||
console.log('Usage: tsx prepare-template-for-export.ts <template-name>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const possibleTemplates = (await readdir(TEMPLATE_DIR, { withFileTypes: true }))
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name)
|
||||
|
||||
if (!possibleTemplates.includes(templateName)) {
|
||||
console.log(`Template "${templateName}" not found.`)
|
||||
console.log(`Possible templates: ${possibleTemplates.join(', ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const sourceDir = join(REPO_ROOT, 'templates', templateName)
|
||||
const githubAuth = getGithubAuth()
|
||||
|
||||
// make a temporary working dir:
|
||||
const workingDir = await mkdtemp(join(tmpdir(), `template-export-${templateName}`))
|
||||
console.log(`Working in ${workingDir}`)
|
||||
|
||||
// read the package.json:
|
||||
const packageJsonRaw = await readJsonIfExists(join(sourceDir, 'package.json'))
|
||||
if (!packageJsonRaw) {
|
||||
console.log('No package.json found')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const packageJson = PackageJson.validate(packageJsonRaw)
|
||||
const exportConfig = packageJson[EXPORT_CONFIG_KEY]
|
||||
if (!exportConfig) {
|
||||
console.log('No export config found in package.json.')
|
||||
console.log(`Please add a "${EXPORT_CONFIG_KEY}" key to your package.json.`)
|
||||
console.log('Skipping for now.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// clone the template repo:
|
||||
const repoUrl = `https://${githubAuth}github.com/${exportConfig.repo}.git`
|
||||
console.log(`Cloning ${repoUrl}...`)
|
||||
await exec('git', ['clone', repoUrl, workingDir, '--depth', '1'])
|
||||
|
||||
console.log('Clearing old files...')
|
||||
const oldFiles = (await readdir(workingDir)).filter((f) => f !== '.git')
|
||||
for (const file of oldFiles) {
|
||||
await rm(join(workingDir, file), { recursive: true })
|
||||
}
|
||||
|
||||
console.log('Copying files to export dir...')
|
||||
const templateRepoPrefix = `templates/${templateName}/`
|
||||
const templateFiles = (await exec('git', ['ls-files', templateRepoPrefix]))
|
||||
.split('\n')
|
||||
.map((s) => s.replace(templateRepoPrefix, ''))
|
||||
.filter((s) => s.trim())
|
||||
// Monorepo-only Vercel deploy config; its commands don't work outside this workspace.
|
||||
.filter((s) => s !== 'vercel.json')
|
||||
|
||||
await mkdir(workingDir, { recursive: true })
|
||||
|
||||
for (const file of templateFiles) {
|
||||
const targetFile = join(workingDir, file)
|
||||
await mkdir(dirname(targetFile), { recursive: true })
|
||||
if (isTypeScriptSourceFile(file)) {
|
||||
const source = await readFile(join(REPO_ROOT, templateRepoPrefix, file), 'utf-8')
|
||||
const cleanedSource = source.replace(
|
||||
/\/\* EXCLUDE_FROM_TEMPLATE_EXPORT_START \*\/(.*?)\/\* EXCLUDE_FROM_TEMPLATE_EXPORT_END \*\//gs,
|
||||
''
|
||||
)
|
||||
await writeCodeFile(null, 'typescript', targetFile, cleanedSource)
|
||||
} else {
|
||||
await copyFile(join(REPO_ROOT, templateRepoPrefix, file), targetFile)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Cleaning package.json...')
|
||||
delete packageJson[EXPORT_CONFIG_KEY]
|
||||
|
||||
if (exportConfig?.scripts) {
|
||||
if (!packageJson.scripts) {
|
||||
packageJson.scripts = {}
|
||||
}
|
||||
for (const [name, script] of Object.entries(exportConfig.scripts)) {
|
||||
if (script === null) {
|
||||
delete packageJson.scripts[name]
|
||||
} else {
|
||||
packageJson.scripts[name] = script
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await setWorkspaceDependenciesToLatest(packageJson.dependencies)
|
||||
await setWorkspaceDependenciesToLatest(packageJson.devDependencies)
|
||||
await setWorkspaceDependenciesToLatest(packageJson.peerDependencies)
|
||||
|
||||
await writeJsonFile(join(workingDir, 'package.json'), packageJson)
|
||||
|
||||
console.log('Cleaning tsconfig.json...')
|
||||
const tsconfigJsonRaw = await readJsonIfExists(join(workingDir, 'tsconfig.json'))
|
||||
if (!tsconfigJsonRaw) {
|
||||
console.log('No tsconfig.json found')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const tsconfigJson = TsConfigJson.validate(tsconfigJsonRaw)
|
||||
if (tsconfigJson.references) {
|
||||
tsconfigJson.references = tsconfigJson.references.filter((ref) => !ref.path.startsWith('.'))
|
||||
if (tsconfigJson.references.length === 0) {
|
||||
delete tsconfigJson.references
|
||||
}
|
||||
}
|
||||
|
||||
await writeJsonFile(join(workingDir, 'tsconfig.json'), tsconfigJson)
|
||||
|
||||
console.log('Committing...')
|
||||
const latestTldrawVersion = await getLatestPackageVersion('tldraw')
|
||||
await exec('git', ['add', '.'], { pwd: workingDir })
|
||||
await exec(
|
||||
'git',
|
||||
['commit', '--allow-empty', '-m', `update from tldraw ${latestTldrawVersion}`],
|
||||
{ pwd: workingDir }
|
||||
)
|
||||
|
||||
console.log('Pushing...')
|
||||
await exec('git', ['push', repoUrl, 'main'], { pwd: workingDir })
|
||||
|
||||
console.log('Cleaning up temp dir...')
|
||||
await rm(workingDir, { recursive: true })
|
||||
|
||||
console.log('Done!')
|
||||
}
|
||||
|
||||
function getGithubAuth() {
|
||||
const githubToken = process.env.GITHUB_TOKEN
|
||||
if (!githubToken) return ''
|
||||
|
||||
return `huppy-bot:${githubToken}@`
|
||||
}
|
||||
|
||||
async function setWorkspaceDependenciesToLatest(dependencies?: Record<string, string>) {
|
||||
if (!dependencies) return
|
||||
for (const [name, version] of Object.entries(dependencies)) {
|
||||
if (version.startsWith('workspace:')) {
|
||||
const latestVersion = await getLatestPackageVersion(name)
|
||||
dependencies[name] = `^${latestVersion}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getLatestPackageVersion(packageName: string) {
|
||||
const latestVersion = await exec('npm', ['view', `${packageName}@latest`, 'version'])
|
||||
return latestVersion.trim()
|
||||
}
|
||||
|
||||
function isTypeScriptSourceFile(path: string) {
|
||||
return path.endsWith('.ts') || path.endsWith('.tsx')
|
||||
}
|
||||
|
||||
main()
|
||||
258
internal/scripts/extract-draft-changelog.tsx
Normal file
258
internal/scripts/extract-draft-changelog.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { join } from 'path'
|
||||
import { exec } from './lib/exec'
|
||||
import { REPO_ROOT, writeStringFile } from './lib/file'
|
||||
import { nicelog } from './lib/nicelog'
|
||||
|
||||
function convertPRLinks(text: string): string {
|
||||
// Convert (#1234) to ([#1234](https://github.com/tldraw/tldraw/issues/1234))
|
||||
return text.replace(/\(#(\d+)\)/g, '([#$1](https://github.com/tldraw/tldraw/issues/$1))')
|
||||
}
|
||||
|
||||
function extractRelevantSections(message: string): string {
|
||||
// Extract Release Notes and API Changes sections (case-insensitive, flexible markdown heading levels)
|
||||
const lines = message.split('\n')
|
||||
const relevantSections: string[] = []
|
||||
let inRelevantSection = false
|
||||
let currentSection: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
// Check if this is a heading line (one or more #s followed by text)
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/i)
|
||||
if (headingMatch) {
|
||||
const headingText = headingMatch[2].trim().toLowerCase()
|
||||
|
||||
// If we were in a relevant section, save it
|
||||
if (inRelevantSection && currentSection.length > 0) {
|
||||
relevantSections.push(currentSection.join('\n'))
|
||||
currentSection = []
|
||||
}
|
||||
|
||||
// Check if this is a Release Notes or API Changes heading
|
||||
if (headingText.includes('release note') || headingText.includes('api change')) {
|
||||
inRelevantSection = true
|
||||
// Include the heading itself in the section
|
||||
currentSection.push(line)
|
||||
} else {
|
||||
inRelevantSection = false
|
||||
}
|
||||
} else if (inRelevantSection) {
|
||||
// We're in a relevant section, collect the line
|
||||
currentSection.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last section
|
||||
if (inRelevantSection && currentSection.length > 0) {
|
||||
relevantSections.push(currentSection.join('\n'))
|
||||
}
|
||||
|
||||
// If we found relevant sections, return them joined together
|
||||
if (relevantSections.length > 0) {
|
||||
// Remove the section headings themselves (first line of each section)
|
||||
const sectionsWithoutHeadings = relevantSections
|
||||
.map((section) => {
|
||||
const sectionLines = section.split('\n')
|
||||
// Remove the first line (the heading) and keep the rest
|
||||
return sectionLines.slice(1).join('\n').trim()
|
||||
})
|
||||
.filter((section) => section.length > 0)
|
||||
|
||||
return sectionsWithoutHeadings.join('\n\n').trim()
|
||||
}
|
||||
|
||||
// Otherwise, return empty string (no content to include)
|
||||
return ''
|
||||
}
|
||||
|
||||
async function getLatestMinorRelease() {
|
||||
// otherwise get the latest minor release
|
||||
const tags = (await exec('git', ['tag', '--list', `v*.*.0`, '--sort=-version:refname']))
|
||||
.toString()
|
||||
.trim()
|
||||
return tags.split('\n')[0]
|
||||
}
|
||||
|
||||
export async function extractChangelog(startRef: string, endRef: string): Promise<string> {
|
||||
nicelog(`Extracting changelog from ${startRef} to ${endRef}`)
|
||||
|
||||
const commitHashes = (await exec('git', ['cherry', startRef, endRef]))
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => !line.startsWith('-'))
|
||||
.map((line) => line.split(' ')[1])
|
||||
|
||||
nicelog(`Found ${commitHashes.length} commits`)
|
||||
|
||||
// Extract full commit messages for each commit
|
||||
const entries: Array<{
|
||||
firstLine: string
|
||||
restOfMessage: string
|
||||
changeType: string
|
||||
changedFolders: string[]
|
||||
}> = []
|
||||
|
||||
for (const hash of commitHashes) {
|
||||
const fullMessage = await exec('git', ['log', '-1', '--pretty=format:%B', hash], {
|
||||
processStdoutLine: () => {}, // Suppress output
|
||||
processStderrLine: () => {},
|
||||
})
|
||||
|
||||
const lines = fullMessage.trim().split('\n')
|
||||
const firstLine = lines[0] || hash
|
||||
|
||||
// Skip commits with [skip ci] in the title
|
||||
if (firstLine.includes('[skip ci]')) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get changed files for this commit
|
||||
const changedFiles = await exec('git', ['show', '--name-only', '--pretty=format:', hash], {
|
||||
processStdoutLine: () => {}, // Suppress output
|
||||
processStderrLine: () => {},
|
||||
})
|
||||
|
||||
// Extract unique folders (max 2 levels deep) from changed files
|
||||
const folders = new Set<string>()
|
||||
changedFiles
|
||||
.split('\n')
|
||||
.filter((file) => file.trim())
|
||||
.forEach((file) => {
|
||||
const parts = file.split('/')
|
||||
if (parts.length >= 2) {
|
||||
folders.add(`${parts[0]}/${parts[1]}`)
|
||||
} else if (parts.length === 1) {
|
||||
folders.add(parts[0])
|
||||
}
|
||||
})
|
||||
|
||||
let restOfMessage = lines.slice(1).join('\n').trim()
|
||||
|
||||
// Extract change type from checkboxes
|
||||
let changeType = 'other'
|
||||
const changeTypes = ['bugfix', 'improvement', 'feature', 'api', 'other']
|
||||
|
||||
for (const type of changeTypes) {
|
||||
if (restOfMessage.includes(`- [x] \`${type}\``)) {
|
||||
changeType = type
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the Change type section and all checkboxes
|
||||
restOfMessage = restOfMessage
|
||||
// remove cursor summary
|
||||
.replace(/<!-- CURSOR_SUMMARY -->(.|\s)*/gm, '')
|
||||
// remove Change type section
|
||||
.replace(/### Change type[\s\n]*/g, '')
|
||||
// remove checkboxes
|
||||
.replace(/- \[[ x]\] `(bugfix|improvement|feature|api|other)`[\s\n]*/g, '')
|
||||
// remove co-authored-by lines
|
||||
.replace(/^co-authored-by: .*$/gim, '')
|
||||
// Clean up excessive newlines
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
// remove trailing horizontal rule
|
||||
.trim()
|
||||
.replace(/-{3,}$/, '')
|
||||
.trim()
|
||||
|
||||
// Extract only Release Notes and API Changes sections
|
||||
const extractedContent = extractRelevantSections(restOfMessage)
|
||||
|
||||
// Convert PR links like (#1234) to full URLs
|
||||
const contentWithLinks = convertPRLinks(extractedContent)
|
||||
|
||||
// Only include entries that touch packages/ folder (but not internal packages)
|
||||
const excludedPackages = ['packages/dotcom-shared', 'packages/worker-shared']
|
||||
const touchesPackages = Array.from(folders).some(
|
||||
(folder) => folder.startsWith('packages/') && !excludedPackages.includes(folder)
|
||||
)
|
||||
if (touchesPackages) {
|
||||
entries.push({
|
||||
firstLine: convertPRLinks(firstLine),
|
||||
restOfMessage: contentWithLinks,
|
||||
changeType,
|
||||
changedFolders: Array.from(folders).sort(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Group entries by change type
|
||||
const groupedEntries: Record<string, typeof entries> = {
|
||||
bugfix: [],
|
||||
improvement: [],
|
||||
feature: [],
|
||||
api: [],
|
||||
other: [],
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
groupedEntries[entry.changeType].push(entry)
|
||||
}
|
||||
|
||||
// Generate output.md content
|
||||
let output = `Generated from commits between \`${startRef}\` and \`${endRef}\`\n\n`
|
||||
|
||||
// Output each change type section
|
||||
const changeTypeLabels = {
|
||||
bugfix: 'Bug Fixes',
|
||||
improvement: 'Improvements',
|
||||
feature: 'Features',
|
||||
api: 'API Changes',
|
||||
other: 'Other Changes',
|
||||
}
|
||||
|
||||
for (const [changeType, label] of Object.entries(changeTypeLabels)) {
|
||||
const entriesForType = groupedEntries[changeType]
|
||||
if (entriesForType.length === 0) continue
|
||||
|
||||
output += `## ${label}\n\n`
|
||||
|
||||
for (const entry of entriesForType) {
|
||||
output += `### ${entry.firstLine}\n\n`
|
||||
|
||||
if (entry.restOfMessage) {
|
||||
output += `${entry.restOfMessage}\n\n`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nicelog(`Extracted ${entries.length} changelog entries`)
|
||||
return output
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2)
|
||||
|
||||
await exec('git', ['fetch', 'origin'])
|
||||
await exec('git', ['fetch', 'origin', '--tags'])
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.error('Usage: yarn extract-draft-changelog [start-ref] [end-ref]')
|
||||
console.error('Example: yarn extract-draft-changelog v2.0.0 HEAD')
|
||||
console.error('Example: yarn extract-draft-changelog v2.0.0 # uses current HEAD')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const startRef = args[0] || (await getLatestMinorRelease())
|
||||
const endRef = args[1] || 'origin/production'
|
||||
|
||||
try {
|
||||
const output = await extractChangelog(startRef, endRef)
|
||||
|
||||
// Write to changelog.md in the repo root
|
||||
const outputPath = join(REPO_ROOT, 'changelog.md')
|
||||
await writeStringFile(outputPath, output)
|
||||
|
||||
nicelog(`Draft changelog written to changelog.md`)
|
||||
} catch (error) {
|
||||
console.error('Error extracting changelog:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error('Unhandled error:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
126
internal/scripts/fetch-fly-logs.ts
Normal file
126
internal/scripts/fetch-fly-logs.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Fetch logs from Fly.io's VictoriaLogs API.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx internal/scripts/fetch-fly-logs.ts [options]
|
||||
*
|
||||
* Examples:
|
||||
* npx tsx internal/scripts/fetch-fly-logs.ts --app production-zero-rm --last 2h
|
||||
* npx tsx internal/scripts/fetch-fly-logs.ts --app production-zero-rm --last 30m --filter "Purged"
|
||||
* npx tsx internal/scripts/fetch-fly-logs.ts --app production-zero-rm --start 2026-04-15T15:00:00Z --end 2026-04-15T18:00:00Z
|
||||
* npx tsx internal/scripts/fetch-fly-logs.ts --app production-zero-rm --last 1h --filter "NOT DEBUG" -o logs.jsonl
|
||||
* npx tsx internal/scripts/fetch-fly-logs.ts --app production-zero-rm --last 6h --filter "Purged" --pretty
|
||||
* FLY_TOKEN=fo1_xxx npx tsx internal/scripts/fetch-fly-logs.ts --app production-zero-rm --last 1h
|
||||
* npx tsx internal/scripts/fetch-fly-logs.ts --token fo1_xxx --app production-zero-rm --last 1h
|
||||
*
|
||||
* See fly-common.ts for app names and token configuration.
|
||||
*/
|
||||
|
||||
import { FLY_ORG_SLUG, getFlyToken, parseDuration } from './fly-common'
|
||||
|
||||
const BASE_URL = `https://api.fly.io/victorialogs/${FLY_ORG_SLUG}/select/logsql/query`
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2)
|
||||
const opts: Record<string, string> = {}
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--token' || args[i] === '-t') opts.token = args[++i]
|
||||
else if (args[i] === '--app' || args[i] === '-a') opts.app = args[++i]
|
||||
else if (args[i] === '--start') opts.start = args[++i]
|
||||
else if (args[i] === '--end') opts.end = args[++i]
|
||||
else if (args[i] === '--last' || args[i] === '-l') opts.last = args[++i]
|
||||
else if (args[i] === '--filter' || args[i] === '-f') opts.filter = args[++i]
|
||||
else if (args[i] === '--limit') opts.limit = args[++i]
|
||||
else if (args[i] === '--output' || args[i] === '-o') opts.output = args[++i]
|
||||
else if (args[i] === '--pretty' || args[i] === '-p') opts.pretty = 'true'
|
||||
else if (args[i] === '--help' || args[i] === '-h') {
|
||||
console.log(
|
||||
[
|
||||
'Usage: npx tsx internal/scripts/fetch-fly-logs.ts [options]',
|
||||
'',
|
||||
'Options:',
|
||||
' --token, -t Fly API token (falls back to FLY_TOKEN env var)',
|
||||
' --app, -a Fly app name (required)',
|
||||
' --last, -l Duration to look back, e.g. 30m, 2h, 1d (default: 1h)',
|
||||
' --start Start time (ISO 8601), overrides --last',
|
||||
' --end End time (ISO 8601), defaults to now',
|
||||
' --filter, -f Additional LogsQL filter, e.g. "Purged", "NOT DEBUG"',
|
||||
' --limit Max lines (default: unlimited)',
|
||||
' --output, -o Write to file instead of stdout',
|
||||
' --pretty, -p Pretty-print: timestamp + message only',
|
||||
' --help, -h Show this help',
|
||||
].join('\n')
|
||||
)
|
||||
process.exit(0)
|
||||
} else {
|
||||
console.error(`Unknown option: ${args[i]}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
if (!opts.app) {
|
||||
console.error('--app is required')
|
||||
process.exit(1)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs()
|
||||
|
||||
const now = new Date()
|
||||
const end = opts.end ?? now.toISOString()
|
||||
let start: string
|
||||
if (opts.start) {
|
||||
start = opts.start
|
||||
} else {
|
||||
const ms = parseDuration(opts.last ?? '1h', 'ms')
|
||||
start = new Date(now.getTime() - ms).toISOString()
|
||||
}
|
||||
|
||||
let query = `fly.app.name: ${opts.app}`
|
||||
if (opts.filter) query += ` AND ${opts.filter}`
|
||||
|
||||
const params = new URLSearchParams({ query, start, end })
|
||||
if (opts.limit) params.set('limit', opts.limit)
|
||||
|
||||
const token = getFlyToken(opts.token)
|
||||
const url = `${BASE_URL}?${params}`
|
||||
|
||||
const res = await fetch(url, { headers: { Authorization: `FlyV1 ${token}` } })
|
||||
if (!res.ok) {
|
||||
console.error(`HTTP ${res.status}: ${await res.text()}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const body = await res.text()
|
||||
if (!body.trim()) {
|
||||
console.error('No logs found.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
let output: string
|
||||
if (opts.pretty) {
|
||||
output = body
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const entry = JSON.parse(line)
|
||||
return `${entry._time} ${entry._msg}`
|
||||
})
|
||||
.sort()
|
||||
.join('\n')
|
||||
} else {
|
||||
output = body
|
||||
}
|
||||
|
||||
if (opts.output) {
|
||||
const fs = await import('fs')
|
||||
fs.writeFileSync(opts.output, output + '\n')
|
||||
const lines = output.trim().split('\n').length
|
||||
console.error(`Wrote ${lines} lines to ${opts.output}`)
|
||||
} else {
|
||||
process.stdout.write(output + '\n')
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
201
internal/scripts/fetch-fly-metrics.ts
Normal file
201
internal/scripts/fetch-fly-metrics.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Fetch CPU and memory metrics from Fly.io's Prometheus API.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx internal/scripts/fetch-fly-metrics.ts [options]
|
||||
*
|
||||
* Examples:
|
||||
* npx tsx internal/scripts/fetch-fly-metrics.ts --app production-zero-rm --last 1h
|
||||
* npx tsx internal/scripts/fetch-fly-metrics.ts --app production-zero-rm --last 6h --step 5m
|
||||
* npx tsx internal/scripts/fetch-fly-metrics.ts --token fo1_xxx --app production-zero-rm --last 2h
|
||||
* FLY_TOKEN=fo1_xxx npx tsx internal/scripts/fetch-fly-metrics.ts --app production-zero-rm --last 1h
|
||||
*
|
||||
* See fly-common.ts for app names and token configuration.
|
||||
*/
|
||||
|
||||
import { FLY_ORG_SLUG, getFlyToken, parseDuration } from './fly-common'
|
||||
|
||||
const PROM_URL = `https://api.fly.io/prometheus/${FLY_ORG_SLUG}/api/v1/query_range`
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2)
|
||||
const opts: Record<string, string> = {}
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--token' || args[i] === '-t') opts.token = args[++i]
|
||||
else if (args[i] === '--app' || args[i] === '-a') opts.app = args[++i]
|
||||
else if (args[i] === '--last' || args[i] === '-l') opts.last = args[++i]
|
||||
else if (args[i] === '--step' || args[i] === '-s') opts.step = args[++i]
|
||||
else if (args[i] === '--output' || args[i] === '-o') opts.output = args[++i]
|
||||
else if (args[i] === '--help' || args[i] === '-h') {
|
||||
console.log(
|
||||
[
|
||||
'Usage: npx tsx internal/scripts/fetch-fly-metrics.ts [options]',
|
||||
'',
|
||||
'Options:',
|
||||
' --token, -t Fly API token (falls back to FLY_TOKEN env var)',
|
||||
' --app, -a Fly app name (required)',
|
||||
' --last, -l Duration to look back, e.g. 30m, 2h, 1d (default: 1h)',
|
||||
' --step, -s Query resolution step, e.g. 1m, 5m (default: 1m)',
|
||||
' --output, -o Write to file instead of stdout',
|
||||
' --help, -h Show this help',
|
||||
].join('\n')
|
||||
)
|
||||
process.exit(0)
|
||||
} else {
|
||||
console.error(`Unknown option: ${args[i]}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
if (!opts.app) {
|
||||
console.error('--app is required')
|
||||
process.exit(1)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
interface PromResult {
|
||||
metric: Record<string, string>
|
||||
values: [number, string][]
|
||||
}
|
||||
|
||||
async function queryRange(
|
||||
token: string,
|
||||
query: string,
|
||||
start: number,
|
||||
end: number,
|
||||
step: string
|
||||
): Promise<PromResult[]> {
|
||||
const params = new URLSearchParams({
|
||||
query,
|
||||
start: start.toString(),
|
||||
end: end.toString(),
|
||||
step,
|
||||
})
|
||||
const res = await fetch(`${PROM_URL}?${params}`, {
|
||||
headers: { Authorization: `FlyV1 ${token}` },
|
||||
})
|
||||
if (!res.ok) {
|
||||
console.error(`HTTP ${res.status}: ${await res.text()}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const json = (await res.json()) as { data: { result: PromResult[] } }
|
||||
return json.data.result
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)}GB`
|
||||
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)}MB`
|
||||
return `${(bytes / 1e3).toFixed(1)}KB`
|
||||
}
|
||||
|
||||
function formatTime(epoch: number): string {
|
||||
return new Date(epoch * 1000)
|
||||
.toISOString()
|
||||
.replace('T', ' ')
|
||||
.replace(/\.\d+Z/, 'Z')
|
||||
}
|
||||
|
||||
function seriesLabel(metric: Record<string, string>): string {
|
||||
const instance = metric.instance ?? 'unknown'
|
||||
const region = metric.region ?? ''
|
||||
return region ? `${instance} (${region})` : instance
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs()
|
||||
const token = getFlyToken(opts.token)
|
||||
const step = opts.step ?? '1m'
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const rangeSec = parseDuration(opts.last ?? '1h')
|
||||
const start = now - rangeSec
|
||||
const app = opts.app
|
||||
|
||||
// Query A: CPU usage % (excludes idle + steal, normalized by core count)
|
||||
const cpuQuery = `sum(increase(fly_instance_cpu{app="${app}", mode!="idle", mode!="steal"}[60s]))by(instance, region)/60 / sum(count(fly_instance_cpu{app="${app}", mode="idle"})without(cpu))by(instance, region)`
|
||||
// Query B: CPU baseline (shared CPU fraction)
|
||||
const baselineQuery = `mode(fly_instance_cpu_baseline{app="${app}"}) / mode(count(fly_instance_cpu{app="${app}", mode="idle"})without(cpu_id, mode)) * 100`
|
||||
// Query C: CPU throttle indicator
|
||||
const throttleQuery = `avg(rate(fly_instance_cpu_throttle{app="${app}"}[60s])) / count(fly_instance_cpu{app="${app}", mode="idle"})without(cpu_id, mode)`
|
||||
// Memory used
|
||||
const memQuery = `fly_instance_memory_mem_total{app="${app}"} - fly_instance_memory_mem_available{app="${app}"}`
|
||||
const memTotalQuery = `fly_instance_memory_mem_total{app="${app}"}`
|
||||
|
||||
const [cpuResults, baselineResults, throttleResults, memResults, memTotalResults] =
|
||||
await Promise.all([
|
||||
queryRange(token, cpuQuery, start, now, step),
|
||||
queryRange(token, baselineQuery, start, now, step),
|
||||
queryRange(token, throttleQuery, start, now, step),
|
||||
queryRange(token, memQuery, start, now, step),
|
||||
queryRange(token, memTotalQuery, start, now, step),
|
||||
])
|
||||
|
||||
if (!cpuResults.length && !memResults.length) {
|
||||
console.error('No metrics found.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push(`Metrics for ${app} (last ${opts.last ?? '1h'}, step ${step})`)
|
||||
|
||||
// CPU time series
|
||||
for (const series of cpuResults) {
|
||||
const label = seriesLabel(series.metric)
|
||||
const values = series.values.map((v) => parseFloat(v[1]))
|
||||
const avg = values.reduce((a, b) => a + b, 0) / values.length
|
||||
const max = Math.max(...values)
|
||||
lines.push('')
|
||||
lines.push(`--- CPU [${label}] avg=${avg.toFixed(1)}% max=${max.toFixed(1)}% ---`)
|
||||
for (const [ts, val] of series.values) {
|
||||
lines.push(` ${formatTime(ts)} ${parseFloat(val).toFixed(1)}%`)
|
||||
}
|
||||
}
|
||||
|
||||
// CPU baseline
|
||||
for (const series of baselineResults) {
|
||||
const label = seriesLabel(series.metric)
|
||||
const values = series.values.map((v) => parseFloat(v[1]))
|
||||
const avg = values.reduce((a, b) => a + b, 0) / values.length
|
||||
lines.push('')
|
||||
lines.push(`--- CPU baseline [${label}] avg=${avg.toFixed(1)}% ---`)
|
||||
}
|
||||
|
||||
// CPU throttle
|
||||
for (const series of throttleResults) {
|
||||
const values = series.values.map((v) => parseFloat(v[1]))
|
||||
const throttled = values.filter((v) => v > 1).length
|
||||
if (throttled > 0) {
|
||||
lines.push('')
|
||||
lines.push(`--- CPU throttled: ${throttled}/${values.length} samples ---`)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory time series
|
||||
for (const series of memResults) {
|
||||
const label = seriesLabel(series.metric)
|
||||
const values = series.values.map((v) => parseFloat(v[1]))
|
||||
const avg = values.reduce((a, b) => a + b, 0) / values.length
|
||||
const max = Math.max(...values)
|
||||
const totalSeries = memTotalResults.find((t) => t.metric.instance === series.metric.instance)
|
||||
const total = totalSeries?.values[0] ? parseFloat(totalSeries.values[0][1]) : 0
|
||||
const totalStr = total ? ` / ${formatBytes(total)}` : ''
|
||||
lines.push('')
|
||||
lines.push(`--- MEM [${label}] avg=${formatBytes(avg)} max=${formatBytes(max)}${totalStr} ---`)
|
||||
for (const [ts, val] of series.values) {
|
||||
const pct = total ? ` (${((parseFloat(val) / total) * 100).toFixed(1)}%)` : ''
|
||||
lines.push(` ${formatTime(ts)} ${formatBytes(parseFloat(val))}${pct}`)
|
||||
}
|
||||
}
|
||||
|
||||
const output = lines.join('\n')
|
||||
|
||||
if (opts.output) {
|
||||
const fs = await import('fs')
|
||||
fs.writeFileSync(opts.output, output + '\n')
|
||||
console.error(`Wrote to ${opts.output}`)
|
||||
} else {
|
||||
process.stdout.write(output + '\n')
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
321
internal/scripts/fetch-screenshot-metrics.ts
Normal file
321
internal/scripts/fetch-screenshot-metrics.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Fetch board screenshot telemetry (the mcp_shared_board_screenshot dataset events) from the
|
||||
* Cloudflare Analytics Engine SQL API and report failure rate, timeout rate, cache hit rate, and
|
||||
* Browser Run render time per request source (mcp = MCP tool, og = GET og-image route, queue = async
|
||||
* render consumer).
|
||||
*
|
||||
* Render time is wall-clock time around each Browser Run capture (double3), not Cloudflare's billed
|
||||
* browser milliseconds: the BROWSER binding does not surface a per-render billed-ms figure (double4 is
|
||||
* always -1), so wall-clock render time is the available proxy for Browser Run spend.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx internal/scripts/fetch-screenshot-metrics.ts [options]
|
||||
*
|
||||
* Examples:
|
||||
* npx tsx internal/scripts/fetch-screenshot-metrics.ts --last 24h
|
||||
* npx tsx internal/scripts/fetch-screenshot-metrics.ts --worker main-tldraw-multiplayer --last 7d
|
||||
* npx tsx internal/scripts/fetch-screenshot-metrics.ts --check --max-failure-rate 0.2 --max-render-minutes 300
|
||||
*
|
||||
* With --check the script exits non-zero when a threshold is breached, so it can back a scheduled
|
||||
* alert (cron CI job, uptime monitor, etc.) without any extra infrastructure.
|
||||
*
|
||||
* Auth: set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_ANALYTICS_API_TOKEN (an API token with the
|
||||
* "Account Analytics: Read" permission; CLOUDFLARE_API_TOKEN works as a fallback).
|
||||
*/
|
||||
|
||||
const EVENT_NAME = 'mcp_shared_board_screenshot'
|
||||
|
||||
interface Options {
|
||||
lastHours: number
|
||||
worker?: string
|
||||
dataset: string
|
||||
json: boolean
|
||||
check: boolean
|
||||
maxFailureRate?: number
|
||||
maxTimeoutRate?: number
|
||||
minCacheHitRate?: number
|
||||
maxRenderMinutes?: number
|
||||
}
|
||||
|
||||
interface SourceMetrics {
|
||||
source: string
|
||||
requests: number
|
||||
failures: number
|
||||
timeouts: number
|
||||
rateLimited: number
|
||||
cacheHits: number
|
||||
cacheStale: number
|
||||
cacheMisses: number
|
||||
captures: number
|
||||
renderMs: number
|
||||
}
|
||||
|
||||
function parseArgs(): Options {
|
||||
const args = process.argv.slice(2)
|
||||
const opts: Options = {
|
||||
lastHours: 24,
|
||||
dataset: 'MEASURE',
|
||||
json: false,
|
||||
check: false,
|
||||
}
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]
|
||||
if (arg === '--last' || arg === '-l') opts.lastHours = parseDurationHours(args[++i])
|
||||
else if (arg === '--worker' || arg === '-w') opts.worker = args[++i]
|
||||
else if (arg === '--dataset' || arg === '-d') opts.dataset = args[++i]
|
||||
else if (arg === '--json') opts.json = true
|
||||
else if (arg === '--check') opts.check = true
|
||||
else if (arg === '--max-failure-rate') opts.maxFailureRate = Number(args[++i])
|
||||
else if (arg === '--max-timeout-rate') opts.maxTimeoutRate = Number(args[++i])
|
||||
else if (arg === '--min-cache-hit-rate') opts.minCacheHitRate = Number(args[++i])
|
||||
// --max-browser-minutes is the former name; kept as an alias.
|
||||
else if (arg === '--max-render-minutes' || arg === '--max-browser-minutes')
|
||||
opts.maxRenderMinutes = Number(args[++i])
|
||||
else if (arg === '--help' || arg === '-h') {
|
||||
console.log(
|
||||
[
|
||||
'Usage: npx tsx internal/scripts/fetch-screenshot-metrics.ts [options]',
|
||||
'',
|
||||
'Options:',
|
||||
' --last, -l Look-back window, e.g. 1h, 24h, 7d (default: 24h)',
|
||||
' --worker, -w Filter to one worker name, e.g. main-tldraw-multiplayer',
|
||||
' --dataset, -d Analytics Engine dataset name (default: MEASURE)',
|
||||
' --json Print raw per-source metrics as JSON',
|
||||
' --check Exit non-zero when a threshold below is breached',
|
||||
' --max-failure-rate Threshold, 0-1 (default with --check: 0.2)',
|
||||
' --max-timeout-rate Threshold, 0-1 (default with --check: 0.1)',
|
||||
' --min-cache-hit-rate Threshold, 0-1 (no default)',
|
||||
' --max-render-minutes Browser Run render minutes in the window (no default)',
|
||||
' (alias: --max-browser-minutes)',
|
||||
' --help, -h Show this help',
|
||||
].join('\n')
|
||||
)
|
||||
process.exit(0)
|
||||
} else {
|
||||
console.error(`Unknown option: ${arg}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
if (opts.check) {
|
||||
opts.maxFailureRate ??= 0.2
|
||||
opts.maxTimeoutRate ??= 0.1
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
function parseDurationHours(value: string | undefined): number {
|
||||
const match = value?.match(/^(\d+)([hd])$/)
|
||||
if (!match) {
|
||||
console.error(`Invalid duration: ${value}. Use e.g. 6h, 24h, 7d.`)
|
||||
process.exit(1)
|
||||
}
|
||||
const amount = Number(match[1])
|
||||
return match[2] === 'd' ? amount * 24 : amount
|
||||
}
|
||||
|
||||
function getAuth() {
|
||||
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
const apiToken = process.env.CLOUDFLARE_ANALYTICS_API_TOKEN ?? process.env.CLOUDFLARE_API_TOKEN
|
||||
if (!accountId || !apiToken) {
|
||||
console.error(
|
||||
'Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_ANALYTICS_API_TOKEN (or CLOUDFLARE_API_TOKEN).'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
return { accountId, apiToken }
|
||||
}
|
||||
|
||||
async function queryAnalyticsEngine(sql: string): Promise<Record<string, string | number>[]> {
|
||||
const { accountId, apiToken } = getAuth()
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/accounts/${accountId}/analytics_engine/sql`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
body: sql,
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Analytics Engine query failed (${response.status}): ${await response.text()}`)
|
||||
}
|
||||
const result = (await response.json()) as { data: Record<string, string | number>[] }
|
||||
return result.data
|
||||
}
|
||||
|
||||
// Only [0-9a-zA-Z_-] survives; every dynamic value interpolated into SQL goes through this.
|
||||
function sqlSafe(value: string) {
|
||||
return value.replace(/[^0-9a-zA-Z_-]/g, '')
|
||||
}
|
||||
|
||||
async function fetchMetrics(opts: Options): Promise<SourceMetrics[]> {
|
||||
const workerFilter = opts.worker ? `AND blob2 = '${sqlSafe(opts.worker)}'` : ''
|
||||
const where = `blob1 = '${EVENT_NAME}' ${workerFilter} AND timestamp > NOW() - INTERVAL '${opts.lastHours}' HOUR`
|
||||
const dataset = sqlSafe(opts.dataset)
|
||||
|
||||
// blob3 = source, blob4 = cache status, blob5 = failure reason, blob6 = rate limit decision.
|
||||
// _sample_interval corrects for Analytics Engine sampling.
|
||||
const rows = await queryAnalyticsEngine(`
|
||||
SELECT
|
||||
blob3 AS source,
|
||||
blob4 AS cache,
|
||||
blob5 AS failure,
|
||||
blob6 AS rate_limit,
|
||||
SUM(_sample_interval) AS requests
|
||||
FROM ${dataset}
|
||||
WHERE ${where}
|
||||
GROUP BY source, cache, failure, rate_limit
|
||||
FORMAT JSON
|
||||
`)
|
||||
|
||||
// double3 is the wall-clock render duration, positive only on rows that actually invoked Browser
|
||||
// Run (cache hits and pre-render failures leave it at -1). It is the spend proxy: double4 holds
|
||||
// billed browser ms, which the BROWSER binding does not surface, so it is always -1.
|
||||
//
|
||||
// Failed captures count here too, deliberately: a render that threw still created a browser and
|
||||
// held it, often for the whole timeout, so excluding it would understate spend by exactly the
|
||||
// renders most worth knowing the cost of. Queue rows are per delivery, so a job that retried
|
||||
// three times contributes three captures — again, that is what it spent.
|
||||
const spendRows = await queryAnalyticsEngine(`
|
||||
SELECT
|
||||
blob3 AS source,
|
||||
SUM(_sample_interval) AS captures,
|
||||
SUM(double3 * _sample_interval) AS render_ms
|
||||
FROM ${dataset}
|
||||
WHERE ${where} AND double3 > 0
|
||||
GROUP BY source
|
||||
FORMAT JSON
|
||||
`)
|
||||
|
||||
const bySource = new Map<string, SourceMetrics>()
|
||||
const getSource = (source: string) => {
|
||||
let metrics = bySource.get(source)
|
||||
if (!metrics) {
|
||||
metrics = {
|
||||
source,
|
||||
requests: 0,
|
||||
failures: 0,
|
||||
timeouts: 0,
|
||||
rateLimited: 0,
|
||||
cacheHits: 0,
|
||||
cacheStale: 0,
|
||||
cacheMisses: 0,
|
||||
captures: 0,
|
||||
renderMs: 0,
|
||||
}
|
||||
bySource.set(source, metrics)
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const metrics = getSource(String(row.source).replace(/^source:/, ''))
|
||||
const requests = Number(row.requests)
|
||||
const failure = String(row.failure).replace(/^failure:/, '')
|
||||
const cache = String(row.cache).replace(/^cache:/, '')
|
||||
metrics.requests += requests
|
||||
if (failure !== 'none') metrics.failures += requests
|
||||
if (/timeout|abort/i.test(failure)) metrics.timeouts += requests
|
||||
if (String(row.rate_limit) === 'rate_limit:blocked') metrics.rateLimited += requests
|
||||
if (cache === 'hit') metrics.cacheHits += requests
|
||||
if (cache === 'stale') metrics.cacheStale += requests
|
||||
if (cache === 'miss') metrics.cacheMisses += requests
|
||||
}
|
||||
for (const row of spendRows) {
|
||||
const metrics = getSource(String(row.source).replace(/^source:/, ''))
|
||||
metrics.captures += Number(row.captures)
|
||||
metrics.renderMs += Number(row.render_ms)
|
||||
}
|
||||
|
||||
return [...bySource.values()].sort((a, b) => a.source.localeCompare(b.source))
|
||||
}
|
||||
|
||||
function rate(part: number, total: number) {
|
||||
return total === 0 ? 0 : part / total
|
||||
}
|
||||
|
||||
function formatPercent(value: number) {
|
||||
return `${(value * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs()
|
||||
return fetchMetrics(opts).then((metrics) => {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(metrics, null, 2))
|
||||
} else {
|
||||
console.log(
|
||||
`Screenshot telemetry, last ${opts.lastHours}h${opts.worker ? ` (worker ${opts.worker})` : ''}:`
|
||||
)
|
||||
if (metrics.length === 0) console.log(' no events in window')
|
||||
for (const m of metrics) {
|
||||
console.log(
|
||||
[
|
||||
` ${m.source.padEnd(6)}`,
|
||||
`requests ${m.requests}`,
|
||||
`failure ${formatPercent(rate(m.failures, m.requests))}`,
|
||||
`timeout ${formatPercent(rate(m.timeouts, m.requests))}`,
|
||||
`cache hit ${formatPercent(rate(m.cacheHits, m.cacheHits + m.cacheStale + m.cacheMisses))}`,
|
||||
`rate-limited ${m.rateLimited}`,
|
||||
`captures ${m.captures}`,
|
||||
`render ${(m.renderMs / 60_000).toFixed(1)}min`,
|
||||
].join(' ')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.check) return
|
||||
|
||||
const totals = metrics.reduce(
|
||||
(sum, m) => ({
|
||||
requests: sum.requests + m.requests,
|
||||
failures: sum.failures + m.failures,
|
||||
timeouts: sum.timeouts + m.timeouts,
|
||||
cacheHits: sum.cacheHits + m.cacheHits,
|
||||
cacheLookups: sum.cacheLookups + m.cacheHits + m.cacheStale + m.cacheMisses,
|
||||
renderMs: sum.renderMs + m.renderMs,
|
||||
}),
|
||||
{ requests: 0, failures: 0, timeouts: 0, cacheHits: 0, cacheLookups: 0, renderMs: 0 }
|
||||
)
|
||||
|
||||
const breaches: string[] = []
|
||||
const failureRate = rate(totals.failures, totals.requests)
|
||||
const timeoutRate = rate(totals.timeouts, totals.requests)
|
||||
const cacheHitRate = rate(totals.cacheHits, totals.cacheLookups)
|
||||
const renderMinutes = totals.renderMs / 60_000
|
||||
if (opts.maxFailureRate !== undefined && failureRate > opts.maxFailureRate) {
|
||||
breaches.push(
|
||||
`failure rate ${formatPercent(failureRate)} > ${formatPercent(opts.maxFailureRate)}`
|
||||
)
|
||||
}
|
||||
if (opts.maxTimeoutRate !== undefined && timeoutRate > opts.maxTimeoutRate) {
|
||||
breaches.push(
|
||||
`timeout rate ${formatPercent(timeoutRate)} > ${formatPercent(opts.maxTimeoutRate)}`
|
||||
)
|
||||
}
|
||||
if (
|
||||
opts.minCacheHitRate !== undefined &&
|
||||
totals.cacheLookups > 0 &&
|
||||
cacheHitRate < opts.minCacheHitRate
|
||||
) {
|
||||
breaches.push(
|
||||
`cache hit rate ${formatPercent(cacheHitRate)} < ${formatPercent(opts.minCacheHitRate)}`
|
||||
)
|
||||
}
|
||||
if (opts.maxRenderMinutes !== undefined && renderMinutes > opts.maxRenderMinutes) {
|
||||
breaches.push(
|
||||
`Browser Run render time ${renderMinutes.toFixed(1)}min > ${opts.maxRenderMinutes}min`
|
||||
)
|
||||
}
|
||||
|
||||
if (breaches.length > 0) {
|
||||
console.error(`ALERT: ${breaches.join('; ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('All thresholds OK')
|
||||
})
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exit(1)
|
||||
})
|
||||
38
internal/scripts/fly-common.ts
Normal file
38
internal/scripts/fly-common.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Shared utilities for Fly.io scripts.
|
||||
*
|
||||
* App names:
|
||||
* production-zero-rm Production replication manager
|
||||
* production-zero-vs Production view syncer
|
||||
* staging-zero-rm Staging replication manager
|
||||
* staging-zero-vs Staging view syncer
|
||||
* pr-NNNN-zero-cache PR preview (single zero-cache process)
|
||||
*/
|
||||
|
||||
export const FLY_ORG_SLUG = process.env.FLY_ORG_SLUG ?? 'tldraw-gb-ltd'
|
||||
|
||||
export function getFlyToken(explicit?: string): string {
|
||||
let token = explicit ?? process.env.FLY_TOKEN
|
||||
if (!token) {
|
||||
console.error(
|
||||
'No token provided. Use --token, FLY_TOKEN env var, or create one with: fly tokens create readonly'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
// Strip "FlyV1 " prefix if present — callers add it themselves
|
||||
if (token.startsWith('FlyV1 ')) token = token.slice(6)
|
||||
return token
|
||||
}
|
||||
|
||||
export function parseDuration(s: string, unit: 'ms' | 's' = 's'): number {
|
||||
const match = s.match(/^(\d+)(m|h|d)$/)
|
||||
if (!match) {
|
||||
console.error(`Invalid duration: ${s}. Use e.g. 30m, 2h, 1d`)
|
||||
process.exit(1)
|
||||
}
|
||||
const n = parseInt(match[1])
|
||||
const u = match[2]
|
||||
const multipliers: Record<string, number> = { m: 60, h: 3_600, d: 86_400 }
|
||||
const sec = n * multipliers[u]
|
||||
return unit === 'ms' ? sec * 1000 : sec
|
||||
}
|
||||
257
internal/scripts/generate-test-licenses.ts
Normal file
257
internal/scripts/generate-test-licenses.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Test License Key Generator
|
||||
*
|
||||
* This script generates various license keys for manual testing of the LicenseManager.
|
||||
* Uses the existing test utilities from LicenseManager.test.ts
|
||||
*/
|
||||
|
||||
import crypto from 'crypto'
|
||||
import { FLAGS } from '../../packages/editor/src/lib/license/LicenseManager'
|
||||
import { str2ab } from '../../packages/editor/src/lib/utils/licensing'
|
||||
// Utility function copied from LicenseManager.test.ts to avoid import issues
|
||||
function ab2str(buf: ArrayBuffer) {
|
||||
return String.fromCharCode.apply(null, Array.from(new Uint8Array(buf)))
|
||||
}
|
||||
|
||||
// Utility functions copied from LicenseManager.test.ts (not exported)
|
||||
async function generateLicenseKey(
|
||||
message: string,
|
||||
keyPair: { publicKey: string; privateKey: string }
|
||||
) {
|
||||
const enc = new TextEncoder()
|
||||
const encodedMsg = enc.encode(message)
|
||||
const privateKey = await importPrivateKey(keyPair.privateKey)
|
||||
|
||||
const signedLicenseKeyBuffer = await crypto.subtle.sign(
|
||||
{
|
||||
name: 'ECDSA',
|
||||
hash: { name: 'SHA-256' },
|
||||
},
|
||||
privateKey,
|
||||
encodedMsg
|
||||
)
|
||||
|
||||
const signature = btoa(ab2str(signedLicenseKeyBuffer))
|
||||
const prefix = 'tldraw-'
|
||||
const licenseKey = `${prefix}/${btoa(message)}.${signature}`
|
||||
|
||||
return licenseKey
|
||||
}
|
||||
|
||||
function importPrivateKey(pemContents: string) {
|
||||
const binaryDerString = atob(pemContents)
|
||||
const binaryDer = str2ab(binaryDerString)
|
||||
|
||||
return crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
new Uint8Array(binaryDer),
|
||||
{
|
||||
name: 'ECDSA',
|
||||
namedCurve: 'P-256',
|
||||
},
|
||||
true,
|
||||
['sign']
|
||||
)
|
||||
}
|
||||
|
||||
async function exportCryptoKey(key: CryptoKey, isPublic = false) {
|
||||
const exported = await crypto.subtle.exportKey(isPublic ? 'spki' : 'pkcs8', key)
|
||||
return btoa(ab2str(exported))
|
||||
}
|
||||
|
||||
// Generate a fresh key pair for each run
|
||||
async function generateKeyPair() {
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'ECDSA',
|
||||
namedCurve: 'P-256',
|
||||
},
|
||||
true,
|
||||
['sign', 'verify']
|
||||
)
|
||||
const publicKey = await exportCryptoKey(keyPair.publicKey, true /* isPublic */)
|
||||
const privateKey = await exportCryptoKey(keyPair.privateKey)
|
||||
|
||||
return { publicKey, privateKey }
|
||||
}
|
||||
|
||||
function createLicenseInfo(id: string, hosts: string[], flags: number, expiryDate: string) {
|
||||
return JSON.stringify([id, hosts, flags, expiryDate])
|
||||
}
|
||||
|
||||
function getDateOffset(days: number): string {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + days)
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🔑 Generating test license keys for LicenseManager testing...\n')
|
||||
|
||||
console.log('Generating fresh key pair for testing...')
|
||||
const keyPair = await generateKeyPair()
|
||||
console.log('Public Key:', keyPair.publicKey)
|
||||
console.log('Private Key:', keyPair.privateKey)
|
||||
console.log('')
|
||||
|
||||
const testCases = [
|
||||
// === VALID LICENSES ===
|
||||
{
|
||||
name: 'Valid Annual License',
|
||||
info: createLicenseInfo(
|
||||
'test-annual-valid',
|
||||
['localhost'],
|
||||
FLAGS.ANNUAL_LICENSE,
|
||||
getDateOffset(60) // 2 months future
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Valid Perpetual License',
|
||||
info: createLicenseInfo(
|
||||
'test-perpetual-valid',
|
||||
['localhost'],
|
||||
FLAGS.PERPETUAL_LICENSE,
|
||||
getDateOffset(365) // 1 year future
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Valid Evaluation License',
|
||||
info: createLicenseInfo(
|
||||
'test-eval-valid',
|
||||
['localhost'],
|
||||
FLAGS.EVALUATION_LICENSE,
|
||||
getDateOffset(14) // 2 weeks future
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Valid Internal License',
|
||||
info: createLicenseInfo(
|
||||
'test-internal-valid',
|
||||
['localhost'],
|
||||
FLAGS.ANNUAL_LICENSE | FLAGS.INTERNAL_LICENSE,
|
||||
getDateOffset(60) // 2 months future
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'License with Watermark Flag',
|
||||
info: createLicenseInfo(
|
||||
'test-watermark-only',
|
||||
['localhost'],
|
||||
FLAGS.WITH_WATERMARK,
|
||||
getDateOffset(60) // 2 months future
|
||||
),
|
||||
},
|
||||
|
||||
// === FEATURE FLAGS ===
|
||||
{
|
||||
name: 'License with Commenting Feature',
|
||||
info: createLicenseInfo(
|
||||
'test-feat-commenting',
|
||||
['localhost'],
|
||||
FLAGS.ANNUAL_LICENSE | FLAGS.FEAT_COMMENTING,
|
||||
getDateOffset(60) // 2 months future
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'License with Collaboration Feature (grants commenting)',
|
||||
info: createLicenseInfo(
|
||||
'test-feat-collaboration',
|
||||
['localhost'],
|
||||
FLAGS.ANNUAL_LICENSE | FLAGS.FEAT_COLLABORATION,
|
||||
getDateOffset(60) // 2 months future
|
||||
),
|
||||
},
|
||||
|
||||
// === GRACE PERIOD TESTING ===
|
||||
{
|
||||
name: 'Annual License (expired 15 days - within grace period)',
|
||||
info: createLicenseInfo(
|
||||
'test-grace-15days',
|
||||
['localhost'],
|
||||
FLAGS.ANNUAL_LICENSE,
|
||||
getDateOffset(-15) // 15 days past
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Annual License (expired 45 days - beyond grace period)',
|
||||
info: createLicenseInfo(
|
||||
'test-annual-expired-45days',
|
||||
['localhost'],
|
||||
FLAGS.ANNUAL_LICENSE,
|
||||
getDateOffset(-45) // 45 days past
|
||||
),
|
||||
},
|
||||
|
||||
// === EXPIRED INTERNAL LICENSES ===
|
||||
{
|
||||
name: 'Internal License (expired 15 days - within grace period)',
|
||||
info: createLicenseInfo(
|
||||
'test-internal-expired-15days',
|
||||
['localhost'],
|
||||
FLAGS.ANNUAL_LICENSE | FLAGS.INTERNAL_LICENSE,
|
||||
getDateOffset(-15) // 15 days past
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Internal License (expired 45 days - beyond grace period)',
|
||||
info: createLicenseInfo(
|
||||
'test-internal-expired-45days',
|
||||
['localhost'],
|
||||
FLAGS.ANNUAL_LICENSE | FLAGS.INTERNAL_LICENSE,
|
||||
getDateOffset(-45) // 45 days past
|
||||
),
|
||||
},
|
||||
|
||||
// === EXPIRED EVALUATION LICENSES ===
|
||||
{
|
||||
name: 'Evaluation License (expired 1 day)',
|
||||
info: createLicenseInfo(
|
||||
'test-eval-expired-1day',
|
||||
['localhost'],
|
||||
FLAGS.EVALUATION_LICENSE,
|
||||
getDateOffset(-1) // 1 day past
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Evaluation License (expired 15 days)',
|
||||
info: createLicenseInfo(
|
||||
'test-eval-expired-15days',
|
||||
['localhost'],
|
||||
FLAGS.EVALUATION_LICENSE,
|
||||
getDateOffset(-15) // 15 days past
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
console.log('🎫 Generated Test License Keys:\n')
|
||||
|
||||
for (const testCase of testCases) {
|
||||
try {
|
||||
const licenseKey = await generateLicenseKey(testCase.info, keyPair)
|
||||
console.log(`## ${testCase.name}`)
|
||||
console.log(`License Info: ${testCase.info}`)
|
||||
console.log(`License Key: ${licenseKey}`)
|
||||
console.log('')
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to generate license for ${testCase.name}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📝 Usage Instructions:')
|
||||
console.log('')
|
||||
console.log('🚨 DO NOT COMMIT THESE KEYS TO THE REPOSITORY! 🚨')
|
||||
console.log('These are temporary test keys - generate fresh ones for each session')
|
||||
console.log('')
|
||||
console.log('Option 1 - Update LicenseManager (development):')
|
||||
console.log(` Replace publicKey in LicenseManager.ts with: "${keyPair.publicKey}"`)
|
||||
console.log(' Then use: <Tldraw licenseKey="license-key-from-above" />')
|
||||
console.log('')
|
||||
console.log('Option 2 - Pass testPublicKey (automated testing):')
|
||||
console.log(` new LicenseManager("license-key", "${keyPair.publicKey}", "production")`)
|
||||
console.log('')
|
||||
console.log('ℹ️ For production licenses: sales@tldraw.com')
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
334
internal/scripts/generate-tldraw-package-docs.ts
Normal file
334
internal/scripts/generate-tldraw-package-docs.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath, pathToFileURL } from 'url'
|
||||
|
||||
const DOCS_SECTION_IDS = ['getting-started', 'docs', 'starter-kits', 'sdk-features', 'community']
|
||||
const RELEASES_SECTION_ID = 'releases'
|
||||
const RELEASE_NOTES_FILE_NAME = /^v\d+\.\d+\.\d+\.mdx$/
|
||||
const NEXT_RELEASE_NOTES_FILE_NAME = 'next.mdx'
|
||||
const UNWRAPPED_MDX_TAGS = new Set(['Callout', 'Feature', 'FocusLines', 'div'])
|
||||
const OMITTED_MDX_TAGS = new Set(['Image', 'MaskedArticleImage', 'StarterKitBento', 'img'])
|
||||
|
||||
interface Article {
|
||||
content: string
|
||||
fileName: string
|
||||
order: number | null
|
||||
sectionId: string
|
||||
title: string
|
||||
version: [number, number, number] | null
|
||||
}
|
||||
|
||||
interface Section {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function generateTldrawPackageDocs({
|
||||
sourcePackageDir,
|
||||
repoRoot = path.resolve(sourcePackageDir, '../..'),
|
||||
}: {
|
||||
sourcePackageDir: string
|
||||
repoRoot?: string
|
||||
}) {
|
||||
const packageJsonPath = path.join(sourcePackageDir, 'package.json')
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
throw new Error(`No package.json file found in '${sourcePackageDir}'!`)
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
|
||||
const packageVersion = manifest.version
|
||||
const contentDir = path.join(repoRoot, 'apps/docs/content')
|
||||
const sections = readSections(contentDir)
|
||||
|
||||
writeFileSync(
|
||||
path.join(sourcePackageDir, 'DOCS.md'),
|
||||
createDocsRollup({ contentDir, packageVersion, sections })
|
||||
)
|
||||
writeFileSync(
|
||||
path.join(sourcePackageDir, 'RELEASE_NOTES.md'),
|
||||
createReleaseNotesRollup({ contentDir, packageVersion, sections })
|
||||
)
|
||||
}
|
||||
|
||||
export function createDocsRollup({
|
||||
contentDir,
|
||||
packageVersion,
|
||||
sections,
|
||||
}: {
|
||||
contentDir: string
|
||||
packageVersion: string
|
||||
sections: Section[]
|
||||
}) {
|
||||
const result = [
|
||||
'# tldraw Documentation',
|
||||
'',
|
||||
`Version: \`${packageVersion}\``,
|
||||
'',
|
||||
'This file is generated during package publishing from the tldraw docs content for this exact package version.',
|
||||
]
|
||||
|
||||
for (const sectionId of DOCS_SECTION_IDS) {
|
||||
const section = sectionById(sections, sectionId)
|
||||
const articles = readArticles(contentDir, sectionId)
|
||||
if (!articles.length) continue
|
||||
|
||||
result.push('', `## ${section.title}`)
|
||||
if (section.description) result.push('', section.description)
|
||||
|
||||
for (const article of articles) {
|
||||
result.push('', `### ${article.title}`, '', normalizeMdxForMarkdown(article.content, 2))
|
||||
}
|
||||
}
|
||||
|
||||
return result.join('\n').trimEnd() + '\n'
|
||||
}
|
||||
|
||||
export function createReleaseNotesRollup({
|
||||
contentDir,
|
||||
packageVersion,
|
||||
sections,
|
||||
}: {
|
||||
contentDir: string
|
||||
packageVersion: string
|
||||
sections: Section[]
|
||||
}) {
|
||||
const section = sectionById(sections, RELEASES_SECTION_ID)
|
||||
const releaseNotes = readReleaseNotesArticles(contentDir, packageVersion)
|
||||
.filter((article): article is Article & { version: [number, number, number] } =>
|
||||
Boolean(article.version)
|
||||
)
|
||||
.sort((a, b) => compareVersions(b.version, a.version))
|
||||
|
||||
const result = [
|
||||
`# ${section.title}`,
|
||||
'',
|
||||
`Version: \`${packageVersion}\``,
|
||||
'',
|
||||
'This file is generated during package publishing from the tldraw release notes content for this exact package version.',
|
||||
]
|
||||
|
||||
for (const article of releaseNotes) {
|
||||
result.push('', `## ${article.title}`, '', normalizeMdxForMarkdown(article.content, 1))
|
||||
}
|
||||
|
||||
return result.join('\n').trimEnd() + '\n'
|
||||
}
|
||||
|
||||
function readReleaseNotesArticles(contentDir: string, packageVersion: string) {
|
||||
const releaseNotes = readArticles(contentDir, RELEASES_SECTION_ID)
|
||||
const packageVersionTuple = getStableVersionTuple(packageVersion)
|
||||
|
||||
if (packageVersionTuple) {
|
||||
const packageVersionTitle = `v${packageVersionTuple.join('.')}`
|
||||
const nextReleaseNotes = readArticle(
|
||||
path.join(contentDir, RELEASES_SECTION_ID, NEXT_RELEASE_NOTES_FILE_NAME),
|
||||
RELEASES_SECTION_ID,
|
||||
NEXT_RELEASE_NOTES_FILE_NAME
|
||||
)
|
||||
if (
|
||||
!releaseNotes.some((article) => article.title === packageVersionTitle) &&
|
||||
nextReleaseNotes
|
||||
) {
|
||||
releaseNotes.push({
|
||||
...nextReleaseNotes,
|
||||
fileName: `${packageVersionTitle}.mdx`,
|
||||
title: packageVersionTitle,
|
||||
version: packageVersionTuple,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return releaseNotes
|
||||
}
|
||||
|
||||
function readSections(contentDir: string): Section[] {
|
||||
return JSON.parse(readFileSync(path.join(contentDir, 'sections.json'), 'utf8'))
|
||||
}
|
||||
|
||||
function sectionById(sections: Section[], sectionId: string) {
|
||||
const section = sections.find((section) => section.id === sectionId)
|
||||
if (!section) {
|
||||
throw new Error(`Could not find docs section '${sectionId}'`)
|
||||
}
|
||||
return section
|
||||
}
|
||||
|
||||
function readArticles(contentDir: string, sectionId: string) {
|
||||
const sectionDir = path.join(contentDir, sectionId)
|
||||
return readdirSync(sectionDir)
|
||||
.filter((fileName) => fileName.endsWith('.mdx') || fileName.endsWith('.md'))
|
||||
.filter(
|
||||
(fileName) => sectionId !== RELEASES_SECTION_ID || RELEASE_NOTES_FILE_NAME.test(fileName)
|
||||
)
|
||||
.map((fileName) => readArticle(path.join(sectionDir, fileName), sectionId, fileName))
|
||||
.filter((article) => article !== null)
|
||||
.sort(sortArticles)
|
||||
}
|
||||
|
||||
function readArticle(filePath: string, sectionId: string, fileName: string): Article | null {
|
||||
const { data, content } = parseFrontMatter(readFileSync(filePath, 'utf8'))
|
||||
if (data.status !== 'published') return null
|
||||
|
||||
const title = data.title ?? path.basename(fileName, path.extname(fileName))
|
||||
const order = data.order ? Number(data.order) : null
|
||||
const versionMatch = fileName.match(/^v(\d+)\.(\d+)\.(\d+)\.mdx$/)
|
||||
|
||||
return {
|
||||
content,
|
||||
fileName,
|
||||
order: Number.isFinite(order) ? order : null,
|
||||
sectionId,
|
||||
title,
|
||||
version: versionMatch
|
||||
? [Number(versionMatch[1]), Number(versionMatch[2]), Number(versionMatch[3])]
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function parseFrontMatter(raw: string): { data: Record<string, string>; content: string } {
|
||||
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
|
||||
if (!match) return { data: {}, content: raw }
|
||||
|
||||
const data: Record<string, string> = {}
|
||||
for (const line of match[1].split(/\r?\n/)) {
|
||||
const lineMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
|
||||
if (!lineMatch) continue
|
||||
|
||||
const [, key, rawValue] = lineMatch
|
||||
let value = rawValue.trim()
|
||||
if (
|
||||
(value.startsWith("'") && value.endsWith("'")) ||
|
||||
(value.startsWith('"') && value.endsWith('"'))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
data[key] = value
|
||||
}
|
||||
|
||||
return { data, content: raw.slice(match[0].length).trim() }
|
||||
}
|
||||
|
||||
function sortArticles(a: Article, b: Article) {
|
||||
const orderA = a.order ?? Number.MAX_SAFE_INTEGER
|
||||
const orderB = b.order ?? Number.MAX_SAFE_INTEGER
|
||||
if (orderA !== orderB) return orderA - orderB
|
||||
return a.title.localeCompare(b.title)
|
||||
}
|
||||
|
||||
function compareVersions(a: [number, number, number], b: [number, number, number]) {
|
||||
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]
|
||||
}
|
||||
|
||||
function getStableVersionTuple(packageVersion: string): [number, number, number] | null {
|
||||
const match = packageVersion.match(/^(\d+)\.(\d+)\.(\d+)$/)
|
||||
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null
|
||||
}
|
||||
|
||||
function normalizeMdxForMarkdown(content: string, headingDemotion: number) {
|
||||
const result: string[] = []
|
||||
let inFence = false
|
||||
let omittedMdxTag: string | null = null
|
||||
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim()
|
||||
if (/^(```|~~~)/.test(trimmed)) {
|
||||
inFence = !inFence
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!inFence) {
|
||||
if (omittedMdxTag) {
|
||||
if (trimmed.includes('/>') || trimmed.includes(`</${omittedMdxTag}>`)) {
|
||||
omittedMdxTag = null
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^import\s.+\sfrom\s/.test(trimmed)) continue
|
||||
if (/^export\s+(const|function|class|type|interface|default|\{)/.test(trimmed)) continue
|
||||
|
||||
const mdxLine = normalizeMdxLine(trimmed)
|
||||
if (mdxLine.kind === 'omit') {
|
||||
omittedMdxTag = mdxLine.tag
|
||||
continue
|
||||
}
|
||||
if (mdxLine.kind === 'skip') continue
|
||||
if (mdxLine.kind === 'replace') {
|
||||
result.push(rewriteLinks(demoteHeading(mdxLine.content, headingDemotion)))
|
||||
continue
|
||||
}
|
||||
|
||||
result.push(rewriteLinks(demoteHeading(line, headingDemotion)))
|
||||
} else {
|
||||
result.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function normalizeMdxLine(
|
||||
trimmed: string
|
||||
):
|
||||
| { kind: 'keep' }
|
||||
| { kind: 'skip' }
|
||||
| { kind: 'omit'; tag: string }
|
||||
| { kind: 'replace'; content: string } {
|
||||
const checkItemMatch = trimmed.match(/^<CheckItem>(.*?)<\/CheckItem>$/)
|
||||
if (checkItemMatch) {
|
||||
return { kind: 'replace', content: `- ${checkItemMatch[1]}` }
|
||||
}
|
||||
|
||||
const tagName = getMdxTagName(trimmed)
|
||||
if (!tagName) return { kind: 'keep' }
|
||||
if (tagName === 'br') return { kind: 'skip' }
|
||||
if (OMITTED_MDX_TAGS.has(tagName)) {
|
||||
return trimmed.includes('/>') || trimmed.includes(`</${tagName}>`)
|
||||
? { kind: 'skip' }
|
||||
: { kind: 'omit', tag: tagName }
|
||||
}
|
||||
if (UNWRAPPED_MDX_TAGS.has(tagName)) {
|
||||
const title = tagName === 'Feature' ? getMdxStringProp(trimmed, 'title') : null
|
||||
return title ? { kind: 'replace', content: `**${title}**` } : { kind: 'skip' }
|
||||
}
|
||||
if (/^<\/?[A-Z][A-Za-z0-9.]*\b/.test(trimmed)) {
|
||||
return { kind: 'skip' }
|
||||
}
|
||||
|
||||
return { kind: 'keep' }
|
||||
}
|
||||
|
||||
function getMdxTagName(trimmed: string) {
|
||||
const match = trimmed.match(/^<\/?([A-Za-z][A-Za-z0-9.]*)\b/)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
function getMdxStringProp(trimmed: string, propName: string) {
|
||||
const match = trimmed.match(new RegExp(`\\b${propName}=(?:"([^"]+)"|'([^']+)')`))
|
||||
return match?.[1] ?? match?.[2] ?? null
|
||||
}
|
||||
|
||||
function demoteHeading(line: string, headingDemotion: number) {
|
||||
return line.replace(/^(#{1,6})(\s+)/, (_, hashes, spacing) => {
|
||||
return '#'.repeat(Math.min(6, hashes.length + headingDemotion)) + spacing
|
||||
})
|
||||
}
|
||||
|
||||
function rewriteLinks(line: string) {
|
||||
return line
|
||||
.replace(/\[`?([^[\]]*?)`?\]\(\?\)/g, '`$1`')
|
||||
.replace(/\]\((\/[^)\s]+)\)/g, '](https://tldraw.dev$1)')
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
generateTldrawPackageDocs({
|
||||
sourcePackageDir: path.resolve(path.normalize(process.argv[2] ?? process.cwd())),
|
||||
repoRoot: path.resolve(scriptDir, '../..'),
|
||||
})
|
||||
}
|
||||
219
internal/scripts/i18n-download-strings.ts
Normal file
219
internal/scripts/i18n-download-strings.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import JSZip from 'jszip'
|
||||
|
||||
const LOKALISE_API_BASE = 'https://api.lokalise.com/api2'
|
||||
const ASYNC_EXPORT_POLL_INTERVAL_MS = 2000
|
||||
const ASYNC_EXPORT_MAX_POLLS = 120
|
||||
|
||||
function formatError(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.stack ?? error.message
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(error, null, 2)
|
||||
} catch {
|
||||
return String(error)
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function getEnv(name: string) {
|
||||
const value = process.env[name]
|
||||
if (!value) throw new Error(`Missing required env var: ${name}`)
|
||||
return value
|
||||
}
|
||||
|
||||
async function lokaliseRequest<T>(path: string, init: RequestInit, apiKey: string): Promise<T> {
|
||||
const response = await fetch(`${LOKALISE_API_BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Token': apiKey,
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
})
|
||||
|
||||
const body = (await response.json()) as unknown
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Lokalise request failed (${response.status}): ${JSON.stringify(body, null, 2)}`
|
||||
)
|
||||
}
|
||||
|
||||
return body as T
|
||||
}
|
||||
|
||||
async function getAsyncBundleUrl(
|
||||
projectId: string,
|
||||
options: Record<string, unknown>,
|
||||
apiKey: string
|
||||
) {
|
||||
const start = await lokaliseRequest<{ process_id?: string }>(
|
||||
`/projects/${projectId}/files/async-download`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(options),
|
||||
},
|
||||
apiKey
|
||||
)
|
||||
|
||||
if (!start.process_id) {
|
||||
throw new Error(`Async export did not return process_id: ${JSON.stringify(start, null, 2)}`)
|
||||
}
|
||||
|
||||
for (let i = 0; i < ASYNC_EXPORT_MAX_POLLS; i++) {
|
||||
const processResult = await lokaliseRequest<{
|
||||
process?: {
|
||||
status?: string
|
||||
message?: string
|
||||
details?: { download_url?: string; bundle_url?: string }
|
||||
}
|
||||
}>(`/projects/${projectId}/processes/${start.process_id}`, { method: 'GET' }, apiKey)
|
||||
|
||||
const status = processResult.process?.status
|
||||
const details = processResult.process?.details
|
||||
const bundleUrl = details?.download_url ?? details?.bundle_url
|
||||
|
||||
if (status === 'finished') {
|
||||
if (!bundleUrl) {
|
||||
throw new Error(
|
||||
`Async export finished but no bundle URL was returned: ${JSON.stringify(processResult, null, 2)}`
|
||||
)
|
||||
}
|
||||
return bundleUrl
|
||||
}
|
||||
|
||||
if (status === 'failed' || status === 'cancelled') {
|
||||
throw new Error(
|
||||
`Async export ${status}: ${processResult.process?.message ?? 'No message'}\n${JSON.stringify(processResult, null, 2)}`
|
||||
)
|
||||
}
|
||||
|
||||
await sleep(ASYNC_EXPORT_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Async export timed out after ${ASYNC_EXPORT_MAX_POLLS} polls for project ${projectId}`
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchAndSave(
|
||||
url: string,
|
||||
dirPath: string,
|
||||
sortJson: boolean,
|
||||
fileResolver: (fileName: string) => string,
|
||||
space: string | number,
|
||||
skipEnglish = false
|
||||
) {
|
||||
const bundleZip = await fetch(url)
|
||||
// Need to unzip the bundle
|
||||
const zipData = await bundleZip.arrayBuffer()
|
||||
const zip = new JSZip()
|
||||
const bundle = await zip.loadAsync(zipData)
|
||||
const locales = Object.keys(bundle.files)
|
||||
for (const locale of locales) {
|
||||
if (bundle.files[locale].dir) {
|
||||
continue
|
||||
}
|
||||
const fileName = fileResolver(bundle.files[locale].name)
|
||||
if (skipEnglish && fileName === 'en.json') {
|
||||
continue
|
||||
}
|
||||
const filePath = path.resolve(dirPath, fileName)
|
||||
const file = await bundle.files[locale].async('text')
|
||||
const json = JSON.parse(file)
|
||||
|
||||
let finalJson = json
|
||||
if (sortJson) {
|
||||
// Sort of annoying, the property order is not guaranteed, so we need to alphabetize the keys.
|
||||
const sortedJson: { [key: string]: Record<string, string> } = {}
|
||||
// We have to go into the subobject to sort the keys.
|
||||
for (const key of Object.keys(json)) {
|
||||
const subJson = json[key]
|
||||
const sortedSubJson: Record<string, string> = {}
|
||||
for (const subKey of Object.keys(subJson).sort()) {
|
||||
sortedSubJson[subKey] = subJson[subKey]
|
||||
}
|
||||
sortedJson[key] = sortedSubJson
|
||||
}
|
||||
finalJson = sortedJson
|
||||
}
|
||||
fs.writeFileSync(filePath, JSON.stringify(finalJson, null, space) + '\n')
|
||||
console.log(`Wrote ${filePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function i18nDownloadStrings() {
|
||||
await i18nDownloadTldrawStrings()
|
||||
await i18nDownloadDotcomStrings()
|
||||
}
|
||||
|
||||
async function i18nDownloadTldrawStrings() {
|
||||
const projectId = getEnv('LOKALISE_TLDRAW_PROJECT_ID')
|
||||
const apiKey = getEnv('LOKALISE_API_TOKEN')
|
||||
const dirPath = path.resolve(__dirname, '../../assets/translations')
|
||||
console.log('Downloading tldraw project files...')
|
||||
|
||||
const bundleUrl = await getAsyncBundleUrl(
|
||||
projectId,
|
||||
{
|
||||
format: 'json',
|
||||
original_filenames: true,
|
||||
export_empty_as: 'skip',
|
||||
},
|
||||
apiKey
|
||||
)
|
||||
|
||||
await fetchAndSave(
|
||||
bundleUrl,
|
||||
dirPath,
|
||||
false /* sort json */,
|
||||
(fileName) => fileName.split('/')[0].replace('_', '-').toLowerCase() + '.json',
|
||||
'\t',
|
||||
true /* skip english */
|
||||
)
|
||||
|
||||
console.log('Downloaded files successfully!')
|
||||
}
|
||||
|
||||
async function i18nDownloadDotcomStrings() {
|
||||
const projectId = getEnv('LOKALISE_PROJECT_ID')
|
||||
const apiKey = getEnv('LOKALISE_API_TOKEN')
|
||||
const dirPath = path.resolve(__dirname, '../../apps/dotcom/client/public/tla/locales')
|
||||
console.log('Downloading dotcom project files...')
|
||||
|
||||
const bundleUrl = await getAsyncBundleUrl(
|
||||
projectId,
|
||||
{
|
||||
format: 'json_structured',
|
||||
original_filenames: true,
|
||||
include_comments: true,
|
||||
include_description: true,
|
||||
export_empty_as: 'skip',
|
||||
plural_format: 'icu',
|
||||
placeholder_format: 'icu',
|
||||
},
|
||||
apiKey
|
||||
)
|
||||
|
||||
await fetchAndSave(
|
||||
bundleUrl,
|
||||
dirPath,
|
||||
true /* sort json */,
|
||||
(fileName) => fileName.split('/')[1].replace('_', '-').toLowerCase(),
|
||||
2
|
||||
)
|
||||
|
||||
console.log('Downloaded files successfully!')
|
||||
}
|
||||
|
||||
i18nDownloadStrings().catch((error) => {
|
||||
console.error('Failed to download i18n strings:')
|
||||
console.error(formatError(error))
|
||||
process.exit(1)
|
||||
})
|
||||
1480
internal/scripts/i18n-fix-strings.ts
Normal file
1480
internal/scripts/i18n-fix-strings.ts
Normal file
File diff suppressed because it is too large
Load Diff
253
internal/scripts/i18n-upload-strings.ts
Normal file
253
internal/scripts/i18n-upload-strings.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { LokaliseApi, QueuedProcess } from '@lokalise/node-api'
|
||||
|
||||
// Pushes the English source strings to Lokalise and orders machine translations
|
||||
// for anything still untranslated (see i18n-download-strings.ts for the other
|
||||
// half of the pipeline).
|
||||
//
|
||||
// Two Lokalise projects are in play:
|
||||
// - the dotcom project (LOKALISE_PROJECT_ID), source apps/dotcom/client/public/tla/locales/en.json
|
||||
// - the tldraw SDK project (LOKALISE_TLDRAW_PROJECT_ID), source assets/translations/main.json
|
||||
// Only the dotcom project orders translations automatically; SDK orders are
|
||||
// still placed by hand.
|
||||
|
||||
const UPLOAD_POLL_INTERVAL_MS = 2000
|
||||
const UPLOAD_MAX_POLLS = 120
|
||||
/** Items per page when listing keys and languages, within Lokalise's per-endpoint maximums. */
|
||||
const PAGE_LIMIT = 500
|
||||
/** Anything pricier than this wants a human to look at it first. */
|
||||
const MAX_ORDER_TOTAL = 10
|
||||
|
||||
function formatError(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.stack ?? error.message
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(error, null, 2)
|
||||
} catch {
|
||||
return String(error)
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function getEnv(name: string) {
|
||||
const value = process.env[name]
|
||||
if (!value) throw new Error(`Missing required env var: ${name}`)
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Lokalise's file upload is asynchronous: it returns a queued process and
|
||||
* creates the keys in the background. Poll until it's done — otherwise anything
|
||||
* we ask about the project next still describes the state from before the
|
||||
* upload, and freshly added strings look like they don't exist.
|
||||
*/
|
||||
async function waitForUpload(lokaliseApi: LokaliseApi, projectId: string, processId: string) {
|
||||
for (let i = 0; i < UPLOAD_MAX_POLLS; i++) {
|
||||
const queuedProcess = await lokaliseApi
|
||||
.queuedProcesses()
|
||||
.get(processId, { project_id: projectId })
|
||||
|
||||
if (queuedProcess.status === 'finished') return queuedProcess
|
||||
if (queuedProcess.status === 'failed' || queuedProcess.status === 'cancelled') {
|
||||
throw new Error(
|
||||
`Upload ${queuedProcess.status}: ${queuedProcess.message || 'No message'}\n${JSON.stringify(queuedProcess, null, 2)}`
|
||||
)
|
||||
}
|
||||
|
||||
await sleep(UPLOAD_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
throw new Error(`Upload timed out after ${UPLOAD_MAX_POLLS} polls for project ${projectId}`)
|
||||
}
|
||||
|
||||
function logUploadResult(queuedProcess: QueuedProcess) {
|
||||
const files = queuedProcess.details?.files ?? []
|
||||
if (!files.length) {
|
||||
console.log(' Upload finished, but Lokalise reported no file details.')
|
||||
return
|
||||
}
|
||||
for (const file of files) {
|
||||
console.log(
|
||||
` ${file.name_original ?? 'file'}: ${file.key_count_inserted ?? 0} inserted, ` +
|
||||
`${file.key_count_updated ?? 0} updated, ${file.key_count_skipped ?? 0} skipped ` +
|
||||
`(${file.key_count_total ?? 0} total).`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFile(
|
||||
lokaliseApi: LokaliseApi,
|
||||
options: { projectId: string; filePath: string; filename: string; cleanupMode: boolean }
|
||||
) {
|
||||
const { projectId, filePath, filename, cleanupMode } = options
|
||||
const file = fs.readFileSync(filePath, 'utf8')
|
||||
console.log(`Uploading ${filename}...`)
|
||||
|
||||
const uploadResult = await lokaliseApi.files().upload(projectId, {
|
||||
data: Buffer.from(file).toString('base64'),
|
||||
filename,
|
||||
lang_iso: 'en',
|
||||
detect_icu_plurals: true,
|
||||
cleanup_mode: cleanupMode,
|
||||
})
|
||||
|
||||
if (!uploadResult.process_id) {
|
||||
throw new Error(`Upload did not return a process_id: ${JSON.stringify(uploadResult, null, 2)}`)
|
||||
}
|
||||
|
||||
const queuedProcess = await waitForUpload(lokaliseApi, projectId, uploadResult.process_id)
|
||||
console.log(`Uploaded ${filename} successfully!`)
|
||||
logUploadResult(queuedProcess)
|
||||
}
|
||||
|
||||
/** Every target language in the project, English excluded. Paginated: there are 50+ of them. */
|
||||
async function listTargetLanguageIsos(lokaliseApi: LokaliseApi, projectId: string) {
|
||||
const isos: string[] = []
|
||||
for (let page = 1; ; page++) {
|
||||
const result = await lokaliseApi
|
||||
.languages()
|
||||
.list({ project_id: projectId, limit: PAGE_LIMIT, page })
|
||||
isos.push(...result.items.map((item) => item.lang_iso))
|
||||
if (result.items.length < PAGE_LIMIT) break
|
||||
}
|
||||
return isos.filter((lang) => lang !== 'en')
|
||||
}
|
||||
|
||||
/** Every key with at least one missing translation. Paginated: a big batch would otherwise truncate. */
|
||||
async function listUntranslatedKeyIds(lokaliseApi: LokaliseApi, projectId: string) {
|
||||
const keyIds: number[] = []
|
||||
let cursor: string | undefined
|
||||
do {
|
||||
const result = await lokaliseApi.keys().list({
|
||||
project_id: projectId,
|
||||
filter_untranslated: 1,
|
||||
pagination: 'cursor',
|
||||
limit: PAGE_LIMIT,
|
||||
...(cursor ? { cursor } : {}),
|
||||
})
|
||||
keyIds.push(...result.items.map((item) => item.key_id))
|
||||
cursor = result.hasNextCursor() ? (result.nextCursor ?? undefined) : undefined
|
||||
} while (cursor)
|
||||
return keyIds
|
||||
}
|
||||
|
||||
async function orderDotcomTranslations(lokaliseApi: LokaliseApi, projectId: string) {
|
||||
const teamId = getEnv('LOKALISE_TEAM_ID')
|
||||
const cardId = getEnv('LOKALISE_CARD_ID')
|
||||
|
||||
const targetLanguageIsos = await listTargetLanguageIsos(lokaliseApi, projectId)
|
||||
const allProjectUntranslatedKeys = await listUntranslatedKeyIds(lokaliseApi, projectId)
|
||||
|
||||
if (!allProjectUntranslatedKeys.length) {
|
||||
console.log('No new strings to translate.')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Found ${allProjectUntranslatedKeys.length} untranslated key(s) across ${targetLanguageIsos.length} language(s).`
|
||||
)
|
||||
|
||||
const orderDetails = {
|
||||
project_id: projectId,
|
||||
payment_method: 'credit_card' as const,
|
||||
card_id: String(cardId),
|
||||
briefing: 'Please translate the following keys',
|
||||
source_language_iso: 'en',
|
||||
target_language_isos: targetLanguageIsos,
|
||||
keys: allProjectUntranslatedKeys,
|
||||
provider_slug: 'google',
|
||||
translation_tier: 1,
|
||||
}
|
||||
|
||||
console.log('Placing test order for new strings...')
|
||||
const placeTranslationOrderDryRun = await lokaliseApi.orders().create(
|
||||
{
|
||||
...orderDetails,
|
||||
dry_run: true,
|
||||
},
|
||||
{
|
||||
team_id: teamId,
|
||||
}
|
||||
)
|
||||
|
||||
// Lokalise won't accept an order that prices out at 0, so there's nothing to
|
||||
// place here. Log enough to diagnose it: the usual cause is that the keys are
|
||||
// untranslated but not billable (already covered by translation memory, or
|
||||
// sitting in an open order from a previous run).
|
||||
if (placeTranslationOrderDryRun.total === 0) {
|
||||
console.log(
|
||||
`There are strings to translate but the dry run priced them at 0, so Lokalise won't take the order.`
|
||||
)
|
||||
console.log(
|
||||
` Untranslated keys (${allProjectUntranslatedKeys.length}): ${allProjectUntranslatedKeys.join(', ')}`
|
||||
)
|
||||
console.log(
|
||||
` Target languages (${targetLanguageIsos.length}): ${targetLanguageIsos.join(', ')}`
|
||||
)
|
||||
console.log(` Dry run response: ${JSON.stringify(placeTranslationOrderDryRun, null, 2)}`)
|
||||
console.log('Place the order manually in Lokalise if these strings need translating.')
|
||||
return
|
||||
}
|
||||
|
||||
if (placeTranslationOrderDryRun.total > MAX_ORDER_TOTAL) {
|
||||
console.error(
|
||||
`Cost of translations (${placeTranslationOrderDryRun.total}) is exceeding expectations (${MAX_ORDER_TOTAL}). Place a manual order.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Placing actual order for new strings (total: ${placeTranslationOrderDryRun.total})...`
|
||||
)
|
||||
await lokaliseApi.orders().create(
|
||||
{
|
||||
...orderDetails,
|
||||
},
|
||||
{
|
||||
team_id: teamId,
|
||||
}
|
||||
)
|
||||
console.log('Finished placing order for new strings.')
|
||||
}
|
||||
|
||||
async function i18nUploadStrings() {
|
||||
const lokaliseApi = new LokaliseApi({ apiKey: getEnv('LOKALISE_API_TOKEN') })
|
||||
const dotcomProjectId = getEnv('LOKALISE_PROJECT_ID')
|
||||
const tldrawProjectId = getEnv('LOKALISE_TLDRAW_PROJECT_ID')
|
||||
|
||||
await uploadFile(lokaliseApi, {
|
||||
projectId: dotcomProjectId,
|
||||
filePath: path.resolve(__dirname, '../../apps/dotcom/client/public/tla/locales/en.json'),
|
||||
filename: 'en.json',
|
||||
cleanupMode: true,
|
||||
})
|
||||
await orderDotcomTranslations(lokaliseApi, dotcomProjectId)
|
||||
|
||||
// The SDK project has no automated ordering — uploading keeps its source
|
||||
// strings current so a manual order can pick them up. It runs last so a
|
||||
// problem here can't stop the dotcom half of the pipeline.
|
||||
// Cleanup mode deletes keys missing from the uploaded file, which is what
|
||||
// keeps the project matching main.json. Both projects export with
|
||||
// original_filenames, and i18n-download-strings.ts maps every file in a
|
||||
// locale's folder to the same <locale>.json, so a stray file here doesn't
|
||||
// show up as extra keys — it silently overwrites a locale's real strings
|
||||
// depending on export order.
|
||||
await uploadFile(lokaliseApi, {
|
||||
projectId: tldrawProjectId,
|
||||
filePath: path.resolve(__dirname, '../../assets/translations/main.json'),
|
||||
filename: 'main.json',
|
||||
cleanupMode: true,
|
||||
})
|
||||
}
|
||||
|
||||
i18nUploadStrings().catch((error) => {
|
||||
console.error('Failed to upload i18n strings:')
|
||||
console.error(formatError(error))
|
||||
process.exit(1)
|
||||
})
|
||||
77
internal/scripts/lib/add-extensions.ts
Normal file
77
internal/scripts/lib/add-extensions.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { existsSync, readFileSync, statSync, writeFileSync } from 'fs'
|
||||
import path from 'path'
|
||||
import { glob } from 'glob'
|
||||
import { parse, print, visit } from 'recast'
|
||||
|
||||
const extensions = ['.js', '.mjs', '.cjs']
|
||||
function resolveRelativePath(importingFile: string, relativePath: string) {
|
||||
if (!relativePath.startsWith('.')) {
|
||||
return relativePath
|
||||
}
|
||||
|
||||
const containingDir = path.dirname(importingFile)
|
||||
|
||||
if (
|
||||
existsSync(path.join(containingDir, relativePath)) &&
|
||||
!statSync(path.join(containingDir, relativePath)).isDirectory()
|
||||
) {
|
||||
// if the file already exists, e.g. .css files, just use it
|
||||
return relativePath
|
||||
}
|
||||
|
||||
// strip the file extension if applicable
|
||||
relativePath.replace(/\.(m|c)?js$/, '')
|
||||
|
||||
for (const extension of extensions) {
|
||||
if (relativePath.endsWith(extension)) {
|
||||
return relativePath
|
||||
} else {
|
||||
let candidate = `${relativePath}${extension}`
|
||||
if (existsSync(path.join(containingDir, candidate))) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
candidate = `${relativePath}/index${extension}`
|
||||
|
||||
if (existsSync(path.join(containingDir, candidate))) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Could not resolve relative path ${relativePath} from ${importingFile}`)
|
||||
}
|
||||
|
||||
export function addJsExtensions(distDir: string) {
|
||||
for (const file of glob.sync(path.join(distDir, '**/*.{mjs,cjs,js}'))) {
|
||||
const code = parse(readFileSync(file, 'utf8'), { parser: require('recast/parsers/typescript') })
|
||||
|
||||
visit(code, {
|
||||
visitImportDeclaration(path) {
|
||||
path.value.source.value = resolveRelativePath(file, path.value.source.value)
|
||||
return false
|
||||
},
|
||||
visitExportAllDeclaration(path) {
|
||||
path.value.source.value = resolveRelativePath(file, path.value.source.value)
|
||||
return false
|
||||
},
|
||||
visitExportNamedDeclaration(path) {
|
||||
if (path.value.source) {
|
||||
path.value.source.value = resolveRelativePath(file, path.value.source.value)
|
||||
}
|
||||
this.traverse(path)
|
||||
},
|
||||
visitCallExpression(path) {
|
||||
if (
|
||||
path.value.callee.type === 'Import' &&
|
||||
path.value.arguments[0]?.type === 'StringLiteral'
|
||||
) {
|
||||
path.value.arguments[0].value = resolveRelativePath(file, path.value.arguments[0].value)
|
||||
}
|
||||
this.traverse(path)
|
||||
},
|
||||
})
|
||||
|
||||
writeFileSync(file, print(code).code)
|
||||
}
|
||||
}
|
||||
217
internal/scripts/lib/deploy.ts
Normal file
217
internal/scripts/lib/deploy.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import * as github from '@actions/github'
|
||||
import { exec } from './exec'
|
||||
|
||||
export function getDeployInfo() {
|
||||
const githubPrNumber = process.env.GITHUB_REF?.match(/refs\/pull\/(\d+)\/merge/)?.[1]
|
||||
|
||||
let previewId = process.env.TLDRAW_PREVIEW_ID
|
||||
if (!previewId && process.env.TLDRAW_ENV === 'preview') {
|
||||
if (githubPrNumber) {
|
||||
previewId = `pr-${githubPrNumber}`
|
||||
} else {
|
||||
throw new Error(
|
||||
'If running preview deploys from outside of a PR action, TLDRAW_PREVIEW_ID env var must be set'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const sha: string | undefined =
|
||||
// if the event is 'pull_request', github.context.sha is an ephemeral merge commit
|
||||
// while the actual commit we want to create the deployment for is the 'head' of the PR.
|
||||
github.context.eventName === 'pull_request'
|
||||
? github.context.payload.pull_request?.head.sha
|
||||
: github.context.sha
|
||||
|
||||
if (!sha) {
|
||||
throw new Error('Could not determine the SHA of the commit to deploy')
|
||||
}
|
||||
|
||||
return {
|
||||
githubPrNumber,
|
||||
previewId,
|
||||
sha,
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a github 'deployment', which creates a 'View Deployment' button in the PR timeline.
|
||||
export async function createGithubDeployment(
|
||||
env: { GH_TOKEN: string; TLDRAW_ENV: string },
|
||||
{
|
||||
app,
|
||||
deploymentUrl,
|
||||
inspectUrl,
|
||||
sha,
|
||||
}: { app: string; deploymentUrl: string; inspectUrl?: string; sha: string }
|
||||
) {
|
||||
const client = github.getOctokit(env.GH_TOKEN)
|
||||
|
||||
const deployment = await client.rest.repos.createDeployment({
|
||||
owner: 'tldraw',
|
||||
repo: 'tldraw',
|
||||
ref: sha,
|
||||
payload: { web_url: deploymentUrl },
|
||||
environment: `${app}-${env.TLDRAW_ENV}`,
|
||||
transient_environment: env.TLDRAW_ENV === 'preview',
|
||||
production_environment: env.TLDRAW_ENV === 'production',
|
||||
required_contexts: [],
|
||||
auto_merge: false,
|
||||
task: 'deploy',
|
||||
})
|
||||
|
||||
await client.rest.repos.createDeploymentStatus({
|
||||
owner: 'tldraw',
|
||||
repo: 'tldraw',
|
||||
deployment_id: (deployment.data as any).id,
|
||||
state: 'success',
|
||||
environment_url: deploymentUrl,
|
||||
log_url: inspectUrl,
|
||||
})
|
||||
}
|
||||
|
||||
/** Deploy a worker to wrangler, returning the deploy ID */
|
||||
export async function wranglerDeploy({
|
||||
location,
|
||||
dryRun,
|
||||
env,
|
||||
vars,
|
||||
sentry,
|
||||
}: {
|
||||
location: string
|
||||
dryRun: boolean
|
||||
env: string
|
||||
vars: Record<string, string>
|
||||
sentry?: {
|
||||
authToken: string
|
||||
project: string
|
||||
environment?: string
|
||||
}
|
||||
}) {
|
||||
const varsArray = []
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
varsArray.push('--var', `${key}:${value}`)
|
||||
}
|
||||
|
||||
const out = await exec(
|
||||
'yarn',
|
||||
[
|
||||
'wrangler',
|
||||
'deploy',
|
||||
dryRun ? '--dry-run' : null,
|
||||
'--env',
|
||||
env,
|
||||
'--outdir',
|
||||
'.wrangler/dist',
|
||||
...varsArray,
|
||||
],
|
||||
{
|
||||
pwd: location,
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
// wrangler needs CI=1 set to prevent it from trying to do interactive prompts
|
||||
CI: '1',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (dryRun) return
|
||||
|
||||
const versionMatch = out.match(/Current Version ID: (.+)/)
|
||||
if (!versionMatch) {
|
||||
throw new Error('Could not find the deploy ID in wrangler output')
|
||||
}
|
||||
|
||||
const workerNameMatch = out.match(/Uploaded ([^ ]+)/)
|
||||
|
||||
if (!workerNameMatch) {
|
||||
throw new Error('Could not find the worker name in wrangler output')
|
||||
}
|
||||
|
||||
if (sentry) {
|
||||
const release = `${sentry.environment ?? workerNameMatch[1]}.${versionMatch[1]}`
|
||||
|
||||
const sentryEnv = {
|
||||
SENTRY_AUTH_TOKEN: sentry.authToken,
|
||||
SENTRY_ORG: 'tldraw',
|
||||
SENTRY_PROJECT: sentry.project,
|
||||
}
|
||||
|
||||
// create a sentry release:
|
||||
await exec('yarn', ['run', '-T', 'sentry-cli', 'releases', 'new', release], {
|
||||
pwd: location,
|
||||
env: sentryEnv,
|
||||
})
|
||||
|
||||
// upload sourcemaps to the release:
|
||||
await exec(
|
||||
'yarn',
|
||||
[
|
||||
'run',
|
||||
'-T',
|
||||
'sentry-cli',
|
||||
'releases',
|
||||
'files',
|
||||
release,
|
||||
'upload-sourcemaps',
|
||||
'.wrangler/dist',
|
||||
],
|
||||
{
|
||||
pwd: location,
|
||||
env: sentryEnv,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServiceBinding {
|
||||
binding: string
|
||||
service: string
|
||||
}
|
||||
|
||||
export async function setWranglerPreviewConfig(
|
||||
location: string,
|
||||
{
|
||||
name,
|
||||
routeHostname,
|
||||
serviceBinding,
|
||||
}: { name: string; routeHostname?: string; serviceBinding?: ServiceBinding },
|
||||
queueName?: string
|
||||
) {
|
||||
// A zone route instead of a custom domain: custom domains mint a dedicated
|
||||
// certificate pack per hostname, while routes are covered by the zone's
|
||||
// universal wildcard cert and the existing proxied *.tldraw.xyz DNS record.
|
||||
const zoneName = routeHostname?.split('.').slice(-2).join('.')
|
||||
const additionalProperties = `name = "${name}"
|
||||
${routeHostname ? `routes = [ { pattern = "${routeHostname}/*", zone_name = "${zoneName}" } ]` : ''}
|
||||
${serviceBinding ? `services = [ {binding = "${serviceBinding.binding}", service = "${serviceBinding.service}" } ]` : ''}`
|
||||
|
||||
const envPreviewSection = `\n[env.preview]\n${additionalProperties}\n`
|
||||
|
||||
const path = join(location, 'wrangler.toml')
|
||||
let data = readFileSync(path).toString()
|
||||
|
||||
// Replace the entire [env.preview] section including all its properties
|
||||
// Match from [env.preview] to the next section or end of file
|
||||
const previewSectionRegex = /\n\[env\.preview\]\n(?:[^[](?:[^\n]*\n)*)?/
|
||||
|
||||
if (previewSectionRegex.test(data)) {
|
||||
data = data.replace(previewSectionRegex, envPreviewSection)
|
||||
} else {
|
||||
data += envPreviewSection
|
||||
}
|
||||
|
||||
if (queueName) {
|
||||
const envPreviewQueuesSection = `\n[[env.preview.queues.producers]]
|
||||
queue = "${queueName}"
|
||||
binding = "QUEUE"
|
||||
|
||||
[[env.preview.queues.consumers]]
|
||||
queue = "${queueName}"
|
||||
max_retries =10
|
||||
`
|
||||
data += envPreviewQueuesSection
|
||||
}
|
||||
|
||||
writeFileSync(path, data)
|
||||
}
|
||||
183
internal/scripts/lib/didAnyPackageChange.ts
Normal file
183
internal/scripts/lib/didAnyPackageChange.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import assert from 'assert'
|
||||
import { writeFileSync } from 'fs'
|
||||
import * as diff from 'diff'
|
||||
import kleur from 'kleur'
|
||||
import * as tar from 'tar'
|
||||
import tmp from 'tmp'
|
||||
import { exec } from './exec'
|
||||
import { nicelog } from './nicelog'
|
||||
import { PackageDetails, getAllPackageDetails } from './publishing'
|
||||
|
||||
async function getPackageFirstDiff(
|
||||
pkg: PackageDetails,
|
||||
publishedVersion: string
|
||||
): Promise<Diff | null> {
|
||||
assert(process.env.TLDRAW_BEMO_URL, 'TLDRAW_BEMO_URL env var must be set')
|
||||
const dir = tmp.dirSync({ unsafeCleanup: true })
|
||||
const dirPath = dir.name
|
||||
try {
|
||||
const unscopedName = pkg.name.replace('@tldraw/', '')
|
||||
const url = `https://registry.npmjs.org/${pkg.name}/-/${unscopedName}-${publishedVersion}.tgz`
|
||||
const res = await fetch(url)
|
||||
if (res.status === 404) {
|
||||
// The package is not published at this version. This happens when a
|
||||
// new package was added to the monorepo since the last release, or
|
||||
// when a previous publish was interrupted after the version-bump
|
||||
// commit landed but before npm publish completed. In either case
|
||||
// treat it as an added package so the release proceeds instead of
|
||||
// failing the entire workflow.
|
||||
nicelog(
|
||||
`No published tarball for ${pkg.name}@${publishedVersion} (${url}); treating as added.`
|
||||
)
|
||||
return { type: 'added', packageName: pkg.name, filePath: pkg.name }
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
throw new Error(`Failed to fetch ${url}: ${res.status}`)
|
||||
}
|
||||
const publishedTarballPath = `${dirPath}/published-package.tgz`
|
||||
writeFileSync(publishedTarballPath, Buffer.from(await res.arrayBuffer()))
|
||||
const publishedManifest = getTarballManifestSync(publishedTarballPath)
|
||||
|
||||
const localTarballPath = `${dirPath}/local-package.tgz`
|
||||
await exec('yarn', ['pack', '--out', localTarballPath], { pwd: pkg.dir, env: process.env })
|
||||
|
||||
const localManifest = getTarballManifestSync(localTarballPath)
|
||||
|
||||
return getManifestFirstDiff(pkg.name, publishedManifest, localManifest)
|
||||
} finally {
|
||||
dir.removeCallback()
|
||||
}
|
||||
}
|
||||
|
||||
type Diff =
|
||||
| {
|
||||
type: 'added' | 'removed'
|
||||
packageName: string
|
||||
filePath: string
|
||||
}
|
||||
| {
|
||||
type: 'changed'
|
||||
packageName: string
|
||||
filePath: string
|
||||
diff: string
|
||||
}
|
||||
|
||||
// In the `tldraw` package these files are generated during publishing from the
|
||||
// docs content in `apps/docs/content` (see `generate-tldraw-package-docs.ts`),
|
||||
// not from SDK source. A docs-only change therefore alters them, which would
|
||||
// otherwise make this diff check report a package change and cut a spurious SDK
|
||||
// patch release. Ignore them for the `tldraw` package so that docs-only patches
|
||||
// don't trigger a new SDK version. Other packages ship a hand-authored DOCS.md
|
||||
// as real source, so we only skip these for `tldraw`.
|
||||
//
|
||||
// `generate-tldraw-package-docs.ts` writes these files to the package root, so
|
||||
// in the tarball they live at exactly `package/<name>` (tarball entry paths are
|
||||
// prefixed with `package/`). Match the full path rather than the basename so a
|
||||
// same-named file in some other folder still counts as a real change.
|
||||
const GENERATED_DOCS_FILES = new Set(['package/DOCS.md', 'package/RELEASE_NOTES.md'])
|
||||
const GENERATED_DOCS_PACKAGE = 'tldraw'
|
||||
|
||||
function isGeneratedDocsFile(packageName: string, filePath: string) {
|
||||
if (packageName !== GENERATED_DOCS_PACKAGE) return false
|
||||
return GENERATED_DOCS_FILES.has(filePath)
|
||||
}
|
||||
|
||||
function getManifestFirstDiff(
|
||||
packageName: string,
|
||||
a: Record<string, Buffer>,
|
||||
b: Record<string, Buffer>
|
||||
): Diff | null {
|
||||
const aKeys = Object.keys(a).filter((key) => !isGeneratedDocsFile(packageName, key))
|
||||
const bKeys = Object.keys(b).filter((key) => !isGeneratedDocsFile(packageName, key))
|
||||
for (const key of aKeys) {
|
||||
if (!bKeys.includes(key)) {
|
||||
return { type: 'removed', packageName, filePath: key }
|
||||
}
|
||||
if (!a[key].equals(b[key])) {
|
||||
return {
|
||||
type: 'changed',
|
||||
packageName,
|
||||
filePath: key,
|
||||
diff: diff.createTwoFilesPatch(key, key, a[key].toString(), b[key].toString()),
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of bKeys) {
|
||||
if (!aKeys.includes(key)) {
|
||||
return { type: 'added', packageName, filePath: key }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getTarballManifestSync(tarballPath: string) {
|
||||
const manifest: Record<string, Buffer> = {}
|
||||
tar.list({
|
||||
file: tarballPath,
|
||||
onentry: (entry) => {
|
||||
entry.on('data', (data) => {
|
||||
// we could hash these to reduce memory but it's probably fine
|
||||
const existing = manifest[entry.path]
|
||||
if (existing) {
|
||||
manifest[entry.path] = Buffer.concat([existing, data])
|
||||
} else {
|
||||
manifest[entry.path] = data
|
||||
}
|
||||
})
|
||||
},
|
||||
sync: true,
|
||||
})
|
||||
|
||||
return manifest
|
||||
}
|
||||
|
||||
export function formatDiff(diff: Diff) {
|
||||
let message = kleur.cyan().bold(diff.packageName) + ' changed:'
|
||||
switch (diff.type) {
|
||||
case 'added':
|
||||
message += `\n New file added ${kleur.green().bold(diff.filePath)}`
|
||||
break
|
||||
case 'removed':
|
||||
message += `\n File removed ${kleur.red().bold(diff.filePath)}`
|
||||
break
|
||||
case 'changed':
|
||||
message += `\n File changed ${kleur.yellow().bold(diff.filePath)}\n\n`
|
||||
message += diff.diff
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
if (line.startsWith('+')) {
|
||||
return kleur.green(line)
|
||||
} else if (line.startsWith('-')) {
|
||||
return kleur.red(line)
|
||||
} else {
|
||||
return line
|
||||
}
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
break
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare each local package against the published tarball at
|
||||
* `publishedVersion` on npm and return the first detected difference, or null
|
||||
* if every package is identical to its published counterpart.
|
||||
*
|
||||
* Callers should pass the latest version that's actually published on npm
|
||||
* (e.g. from `npm show <pkg> version`) rather than the version in the local
|
||||
* `package.json`. The local version can drift ahead of npm if a previous
|
||||
* publish was interrupted after the version-bump commit was pushed; using the
|
||||
* known-published version keeps the diff check robust to that state.
|
||||
*/
|
||||
export async function getAnyPackageDiff(publishedVersion: string): Promise<Diff | null> {
|
||||
const details = await getAllPackageDetails()
|
||||
for (const pkg of Object.values(details)) {
|
||||
const diff = await getPackageFirstDiff(pkg, publishedVersion)
|
||||
if (diff) {
|
||||
return diff
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
124
internal/scripts/lib/discord.ts
Normal file
124
internal/scripts/lib/discord.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
const MASK_PLACEHOLDER = '\x00MASKED\x00'
|
||||
|
||||
function sanitizeVariables(errorOutput: string, secretValues?: string[]): string {
|
||||
let sanitized = errorOutput
|
||||
|
||||
// Value-based masking: replace any occurrence of known secret values (like GitHub Actions).
|
||||
// Uses an internal placeholder so regex patterns below can't re-match masked output.
|
||||
// Sort longest-first so a longer secret is masked before a shorter substring of it.
|
||||
if (secretValues) {
|
||||
const sorted = secretValues.filter((v) => v.length >= 8).sort((a, b) => b.length - a.length)
|
||||
for (const value of sorted) {
|
||||
sanitized = sanitized.replaceAll(value, MASK_PLACEHOLDER)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize wrangler --var KEY:VALUE patterns
|
||||
sanitized = sanitized.replace(/(--var\s+)(\w+):[^ \n]+/g, '$1$2:`***`')
|
||||
|
||||
// Sanitize KEY=VALUE patterns where KEY looks like an env var and value is long enough
|
||||
// to be a secret (e.g. flyctl secrets set). Short values like =debug are left alone.
|
||||
sanitized = sanitized.replace(/\b([A-Z][A-Z_0-9]{2,})=([^ \n]{8,})/g, '$1=`***`')
|
||||
|
||||
// Sanitize --token VALUE and --token=VALUE patterns (e.g. vercel --token xxx)
|
||||
sanitized = sanitized.replace(/(--token[\s=])\S+/g, '$1`***`')
|
||||
|
||||
// Sanitize connection strings (postgres://, redis://, etc.)
|
||||
sanitized = sanitized.replace(
|
||||
/\b(postgres|postgresql|mysql|redis|mongodb|amqp):\/\/[^\s"']+/gi,
|
||||
'$1://`***`'
|
||||
)
|
||||
|
||||
// Replace internal placeholder with final mask
|
||||
sanitized = sanitized.replaceAll(MASK_PLACEHOLDER, '`***`')
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
export class Discord {
|
||||
static AT_TEAM_MENTION = '<@&1414904550917144586>'
|
||||
|
||||
constructor(opts: {
|
||||
webhookUrl: string
|
||||
shouldNotify: boolean
|
||||
totalSteps?: number
|
||||
messagePrefix?: string
|
||||
secretValues?: string[]
|
||||
}) {
|
||||
this.webhookUrl = opts.webhookUrl
|
||||
this.shouldNotify = opts.shouldNotify
|
||||
this.totalSteps = opts.totalSteps ?? 0
|
||||
this.messagePrefix = opts.messagePrefix ?? ''
|
||||
this.secretValues = opts.secretValues
|
||||
}
|
||||
|
||||
webhookUrl: string
|
||||
shouldNotify: boolean
|
||||
totalSteps: number
|
||||
currentStep = 0
|
||||
messagePrefix: string
|
||||
private secretValues?: string[]
|
||||
|
||||
private async send(method: string, url: string, body: unknown): Promise<any> {
|
||||
try {
|
||||
const response = await fetch(`${this.webhookUrl}${url}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`Discord webhook request failed: ${method} -> ${response.status} ${response.statusText}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
return response.json()
|
||||
} catch (err) {
|
||||
const errString = err instanceof Error ? (err.stack ?? err.message) : String(err)
|
||||
console.warn(
|
||||
`Discord webhook request failed: ${method}\n${sanitizeVariables(errString, this.secretValues)}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async message(content: string, { always = false }: { always?: boolean } = {}) {
|
||||
if (!always && !this.shouldNotify) {
|
||||
return {
|
||||
edit: () => {
|
||||
// noop
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const prefixedContent = this.messagePrefix ? `${this.messagePrefix} ${content}` : content
|
||||
const message = await this.send('POST', '?wait=true', {
|
||||
content: sanitizeVariables(prefixedContent, this.secretValues),
|
||||
})
|
||||
|
||||
return {
|
||||
edit: async (newContent: string) => {
|
||||
if (!message?.id) return
|
||||
const prefixedNewContent = this.messagePrefix
|
||||
? `${this.messagePrefix} ${newContent}`
|
||||
: newContent
|
||||
await this.send('PATCH', `/messages/${message.id}`, {
|
||||
content: sanitizeVariables(prefixedNewContent, this.secretValues),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async step<T>(content: string, cb: () => Promise<T>): Promise<T> {
|
||||
this.currentStep++
|
||||
const message = await this.message(`[${this.currentStep}/${this.totalSteps}] ${content}...`)
|
||||
try {
|
||||
const result = await cb()
|
||||
await message.edit(`${content} ✅`)
|
||||
return result
|
||||
} catch (err) {
|
||||
await message.edit(`${content} ❌`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
76
internal/scripts/lib/exec.ts
Normal file
76
internal/scripts/lib/exec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { nicelog } from './nicelog'
|
||||
|
||||
interface ExecOpts {
|
||||
pwd?: string
|
||||
processStdoutLine?(line: string): void
|
||||
processStderrLine?(line: string): void
|
||||
env?: Partial<NodeJS.ProcessEnv>
|
||||
}
|
||||
|
||||
export function prefixOutput(prefix: string) {
|
||||
return {
|
||||
processStdoutLine: (line: string) => process.stdout.write(`${prefix}${line}\n`),
|
||||
processStderrLine: (line: string) => process.stderr.write(`${prefix}${line}\n`),
|
||||
}
|
||||
}
|
||||
|
||||
export async function exec(
|
||||
command: string,
|
||||
args: (string | null)[],
|
||||
{
|
||||
pwd = process.cwd(),
|
||||
processStdoutLine = (line) => process.stdout.write(`${line}\n`),
|
||||
processStderrLine = (line) => process.stderr.write(`${line}\n`),
|
||||
env,
|
||||
}: ExecOpts = {}
|
||||
): Promise<string> {
|
||||
nicelog(`> $ ${command} ${args.join(' ')} (in ${pwd}))`)
|
||||
return new Promise((resolve, reject) => {
|
||||
const data: string[] = []
|
||||
|
||||
const childProcess = execFile(
|
||||
command,
|
||||
args.filter((arg): arg is string => !!arg),
|
||||
{ cwd: pwd, env: { ...process.env, ...env } },
|
||||
(err) => {
|
||||
if (err) reject(err)
|
||||
else resolve(data.join(''))
|
||||
}
|
||||
)
|
||||
|
||||
let pendingStdoutLine = ''
|
||||
childProcess.stdout!.on('data', (chunk) => {
|
||||
const chunkString: string = chunk.toString('utf-8')
|
||||
data.push(chunkString)
|
||||
|
||||
const lines = chunkString.split('\n')
|
||||
lines[0] = pendingStdoutLine + lines[0]
|
||||
pendingStdoutLine = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
processStdoutLine(line)
|
||||
}
|
||||
})
|
||||
childProcess.stdout!.on('close', () => {
|
||||
processStdoutLine(pendingStdoutLine)
|
||||
})
|
||||
|
||||
let pendingStderrLine = ''
|
||||
childProcess.stderr!.on('data', (chunk) => {
|
||||
const chunkString: string = chunk.toString('utf-8')
|
||||
data.push(chunkString)
|
||||
|
||||
const lines = chunkString.split('\n')
|
||||
lines[0] = pendingStderrLine + lines[0]
|
||||
pendingStderrLine = lines.pop() ?? ''
|
||||
|
||||
for (const line of lines) {
|
||||
processStderrLine(line)
|
||||
}
|
||||
})
|
||||
childProcess.stderr!.on('close', () => {
|
||||
processStderrLine(pendingStderrLine)
|
||||
})
|
||||
})
|
||||
}
|
||||
90
internal/scripts/lib/file.ts
Normal file
90
internal/scripts/lib/file.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { readFile, writeFile as writeFileUnchecked } from 'fs/promises'
|
||||
import { dirname, join, relative } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import json5 from 'json5'
|
||||
import { format } from 'oxfmt'
|
||||
import { nicelog } from './nicelog'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
export const REPO_ROOT = join(__dirname, '../../..')
|
||||
|
||||
const _rootPackageJsonPath = join(REPO_ROOT, 'package.json')
|
||||
if (!existsSync(_rootPackageJsonPath)) {
|
||||
throw new Error('expected to find package.json in REPO_ROOT')
|
||||
}
|
||||
const _rootPackageJson = JSON.parse(readFileSync(_rootPackageJsonPath, 'utf8'))
|
||||
if (_rootPackageJson['name'] !== '@tldraw/monorepo') {
|
||||
throw new Error('expected to find @tldraw/monorepo in REPO_ROOT package.json')
|
||||
}
|
||||
|
||||
export async function readJsonIfExists(file: string) {
|
||||
const fileContents = await readFileIfExists(file)
|
||||
if (fileContents === null) {
|
||||
return null
|
||||
}
|
||||
return json5.parse(fileContents)
|
||||
}
|
||||
|
||||
export async function readFileIfExists(file: string) {
|
||||
try {
|
||||
return await readFile(file, 'utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const oxfmtConfig = json5.parse(readFileSync(join(REPO_ROOT, '.oxfmtrc.json'), 'utf8'))
|
||||
export async function writeCodeFile(
|
||||
generator: string | null,
|
||||
language: 'typescript' | 'javascript',
|
||||
filePath: string,
|
||||
code: string
|
||||
) {
|
||||
const source = generator
|
||||
? `
|
||||
// This file is automatically generated by ${generator}.
|
||||
// Do not edit manually. Or do, I'm a comment, not a cop.
|
||||
|
||||
${code}
|
||||
`
|
||||
: code
|
||||
const ext = language === 'typescript' ? 'ts' : 'js'
|
||||
const result = await format(`file.${ext}`, source, oxfmtConfig)
|
||||
await writeStringFile(filePath, result.code)
|
||||
}
|
||||
|
||||
export async function writeStringFile(filePath: string, contents: string) {
|
||||
await writeFile(filePath, Buffer.from(contents, 'utf-8'))
|
||||
}
|
||||
|
||||
export async function writeFile(filePath: string, contents: Buffer) {
|
||||
if (process.env.CI && !process.env.ALLOW_REFRESH_ASSETS_CHANGES) {
|
||||
let existingContents: Buffer | null = null
|
||||
try {
|
||||
existingContents = await readFile(filePath)
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
if (existingContents && !existingContents.equals(contents)) {
|
||||
nicelog(
|
||||
`Asset file ${relative(
|
||||
REPO_ROOT,
|
||||
filePath
|
||||
)} has changed. Please run this script again and commit the changes.`
|
||||
)
|
||||
nicelog('Contents before:')
|
||||
nicelog(existingContents.toString('utf-8'))
|
||||
nicelog('\nContents after:')
|
||||
nicelog(contents.toString('utf-8'))
|
||||
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
await writeFileUnchecked(filePath, contents, 'utf-8')
|
||||
}
|
||||
|
||||
export async function writeJsonFile(filePath: string, contents: unknown) {
|
||||
await writeStringFile(filePath, JSON.stringify(contents, null, '\t') + '\n')
|
||||
}
|
||||
40
internal/scripts/lib/kill-tree.ts
Normal file
40
internal/scripts/lib/kill-tree.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { spawnSync } from 'child_process'
|
||||
|
||||
/**
|
||||
* Collect every descendant PID of `pid` by walking the process tree with `pgrep -P`.
|
||||
*
|
||||
* Returned breadth-first (direct children first, deepest descendants last). Callers that SIGKILL
|
||||
* the tree should reverse this so they kill deepest-first.
|
||||
*/
|
||||
export function descendantPids(pid: number): number[] {
|
||||
const result = spawnSync('pgrep', ['-P', String(pid)], { encoding: 'utf8' })
|
||||
const childPids = (result.stdout ?? '')
|
||||
.split('\n')
|
||||
.map((line) => Number(line.trim()))
|
||||
.filter((n) => Number.isInteger(n) && n > 0)
|
||||
return childPids.flatMap((childPid) => [childPid, ...descendantPids(childPid)])
|
||||
}
|
||||
|
||||
/**
|
||||
* SIGKILL `pid` and its entire descendant tree.
|
||||
*
|
||||
* Our dev orchestrators spawn children through wrapper layers (`yarn run -T tsx …`) and processes
|
||||
* that fork their own grandchildren (vite → esbuild, wrangler → workerd, zero-cache → worker
|
||||
* subprocesses). `child.kill()` only signals the direct child, and SIGKILL is uncatchable and does
|
||||
* not cascade — so killing a wrapper or parent just orphans everything beneath it, leaving processes
|
||||
* that keep holding the fixed dev ports. Walking by PID crosses those wrapper and process-group
|
||||
* boundaries.
|
||||
*
|
||||
* Collect the PIDs up front so the tree does not reparent out from under us while we kill, then
|
||||
* SIGKILL deepest-first. The root `pid` itself is left alone so the caller can finish its own
|
||||
* shutdown and exit. Safe to call from signal and synchronous `exit` handlers.
|
||||
*/
|
||||
export function killProcessTree(pid: number) {
|
||||
for (const target of descendantPids(pid).reverse()) {
|
||||
try {
|
||||
process.kill(target, 'SIGKILL')
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
}
|
||||
44
internal/scripts/lib/labels.ts
Normal file
44
internal/scripts/lib/labels.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
interface Label {
|
||||
// this is what the label is 'called' on github
|
||||
name: string
|
||||
// this is how we describe the label in our pull request template
|
||||
description: string
|
||||
// this is the section title for the label in our changelogs
|
||||
changelogTitle: string
|
||||
}
|
||||
|
||||
const TYPE_LABELS = [
|
||||
{ name: `bugfix`, description: `Bug fix`, changelogTitle: '🐛 Bug Fixes' },
|
||||
{
|
||||
name: `improvement`,
|
||||
description: `Product improvement`,
|
||||
changelogTitle: '💄 Product Improvements',
|
||||
},
|
||||
{
|
||||
name: `feature`,
|
||||
description: `New feature`,
|
||||
changelogTitle: '🎉 New Features',
|
||||
},
|
||||
{
|
||||
name: `api`,
|
||||
description: `API change`,
|
||||
changelogTitle: '🛠️ API Changes',
|
||||
},
|
||||
{
|
||||
name: `other`,
|
||||
description: `Changes that don't affect SDK users, e.g. internal or .com changes`,
|
||||
changelogTitle: '🤷 Other',
|
||||
},
|
||||
] as const satisfies Label[]
|
||||
|
||||
export function getLabelNames() {
|
||||
return [...TYPE_LABELS].map((label) => label.name)
|
||||
}
|
||||
|
||||
function formatTemplateOption(label: Label) {
|
||||
return `- [ ] \`${label.name}\``
|
||||
}
|
||||
|
||||
export function formatLabelOptionsForPRTemplate() {
|
||||
return TYPE_LABELS.map(formatTemplateOption).join('\n')
|
||||
}
|
||||
18
internal/scripts/lib/makeEnv.ts
Normal file
18
internal/scripts/lib/makeEnv.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export function makeEnv<const Keys extends readonly string[]>(
|
||||
keys: Keys
|
||||
): Record<Keys[number], string> {
|
||||
const env = {} as Record<string, string>
|
||||
const missingVars = []
|
||||
for (const key of keys) {
|
||||
const value = process.env[key]
|
||||
if (value === undefined) {
|
||||
missingVars.push(key)
|
||||
continue
|
||||
}
|
||||
env[key] = value
|
||||
}
|
||||
if (missingVars.length > 0) {
|
||||
throw new Error(`Missing environment variables: ${missingVars.join(', ')}`)
|
||||
}
|
||||
return env as Record<Keys[number], string>
|
||||
}
|
||||
3
internal/scripts/lib/nicelog.ts
Normal file
3
internal/scripts/lib/nicelog.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function nicelog(...args: any[]) {
|
||||
console.log(...args)
|
||||
}
|
||||
57
internal/scripts/lib/posthog.ts
Normal file
57
internal/scripts/lib/posthog.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { nicelog } from './nicelog'
|
||||
|
||||
/**
|
||||
* The PostHog project we send CI engineering metrics to. `phc_` keys are public, write-only
|
||||
* ingest keys, so keeping this in the repo is fine — it's the same project the e2e performance
|
||||
* reporter writes to. See https://eu.posthog.com/project/45972
|
||||
*/
|
||||
const CI_METRICS_API_KEY = 'phc_i8oKgMzgV38sn3GfjswW9mevQ3gFlo7bJXekZFeDN6'
|
||||
const DEFAULT_API_HOST = 'https://analytics.tldraw.com/ingest'
|
||||
|
||||
export interface CiMetricEvent {
|
||||
event: string
|
||||
/**
|
||||
* Groups a series together in PostHog. Use a stable string per metric (e.g.
|
||||
* `dotcom-bundle-size`) rather than a per-run id, so a trend over time is one person's events
|
||||
* instead of thousands of one-off people.
|
||||
*/
|
||||
distinctId: string
|
||||
properties: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Send events to the CI metrics PostHog project. Everything goes in a single request to the
|
||||
* `/batch/` endpoint: a build reports a few dozen events, and sending them one at a time would
|
||||
* mean a few dozen round trips at the tail of a deploy, where a failure partway through leaves a
|
||||
* build with some of its events recorded and the rest missing.
|
||||
*/
|
||||
export async function captureCiMetricEvents(events: CiMetricEvent[]) {
|
||||
if (events.length === 0) return
|
||||
|
||||
const apiHost = process.env.POSTHOG_API_HOST || DEFAULT_API_HOST
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
const response = await fetch(`${apiHost}/batch/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
api_key: CI_METRICS_API_KEY,
|
||||
batch: events.map(({ event, distinctId, properties }) => ({
|
||||
event,
|
||||
properties: {
|
||||
...properties,
|
||||
distinct_id: distinctId,
|
||||
$lib: 'tldraw-ci-metrics',
|
||||
$lib_version: '1.0.0',
|
||||
},
|
||||
timestamp,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`PostHog API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
nicelog(`Sent ${events.length} event(s) to PostHog`)
|
||||
}
|
||||
64
internal/scripts/lib/pr-info.ts
Normal file
64
internal/scripts/lib/pr-info.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Octokit } from 'octokit'
|
||||
import { exec } from './exec'
|
||||
import { nicelog } from './nicelog'
|
||||
|
||||
export async function getPrDetailsByNumber(octokit: Octokit, prNumber: number) {
|
||||
return await octokit.rest.pulls
|
||||
.get({
|
||||
owner: 'tldraw',
|
||||
repo: 'tldraw',
|
||||
pull_number: prNumber,
|
||||
})
|
||||
.then((res) => res.data)
|
||||
}
|
||||
|
||||
export async function getPrDetails(octokit: Octokit) {
|
||||
const lastCommitMessage = (await exec('git', ['log', '-1', '--oneline'])).trim()
|
||||
const lastPRNumber = lastCommitMessage.match(/\(#(\d+)\)$/)?.[1]
|
||||
if (!lastPRNumber) {
|
||||
nicelog('No PR number found in last commit message. Exiting...')
|
||||
return null
|
||||
}
|
||||
|
||||
return await getPrDetailsByNumber(octokit, parseInt(lastPRNumber))
|
||||
}
|
||||
|
||||
export async function getPrDetailsAndCommitSha(octokit: Octokit) {
|
||||
const prNumber = process.env.PR_NUMBER
|
||||
nicelog(`PR_NUMBER environment variable: ${prNumber}`)
|
||||
let pr: PullRequest | null
|
||||
let commitSha: string | null = null
|
||||
|
||||
if (prNumber) {
|
||||
// Post-merge mode: get PR details by number
|
||||
nicelog(`Post-merge mode: getting PR details for #${prNumber}`)
|
||||
pr = await getPrDetailsByNumber(octokit, parseInt(prNumber))
|
||||
if (!pr) {
|
||||
nicelog('Could not retrieve PR details. Exiting...')
|
||||
return null
|
||||
}
|
||||
commitSha = pr.merge_commit_sha
|
||||
} else {
|
||||
// Merge-time mode: get PR details from commit message
|
||||
nicelog('Merge-time mode: getting PR details from commit message')
|
||||
pr = await getPrDetails(octokit)
|
||||
if (!pr) {
|
||||
nicelog('Could not retrieve PR details. Exiting...')
|
||||
return null
|
||||
}
|
||||
commitSha = (await exec('git', ['rev-parse', 'HEAD'])).trim()
|
||||
}
|
||||
|
||||
if (!commitSha) {
|
||||
nicelog('PR is not merged or merge commit SHA not available. Exiting...')
|
||||
return null
|
||||
}
|
||||
|
||||
return { pr, commitSha }
|
||||
}
|
||||
|
||||
export type PullRequest = NonNullable<Awaited<ReturnType<typeof getPrDetailsByNumber>>>
|
||||
|
||||
export function labelPresent(pr: PullRequest, labelName: string): boolean {
|
||||
return pr.labels.some((label) => label.name === labelName)
|
||||
}
|
||||
295
internal/scripts/lib/publishing.ts
Normal file
295
internal/scripts/lib/publishing.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { execSync } from 'child_process'
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
|
||||
import path, { join } from 'path'
|
||||
import { Octokit } from '@octokit/rest'
|
||||
import { fetch } from 'cross-fetch'
|
||||
import { glob } from 'glob'
|
||||
import { parse } from 'semver'
|
||||
import { exec } from './exec'
|
||||
import { REPO_ROOT } from './file'
|
||||
import { nicelog } from './nicelog'
|
||||
import { getAllWorkspacePackages } from './workspace'
|
||||
|
||||
export interface PackageDetails {
|
||||
name: string
|
||||
dir: string
|
||||
localDeps: string[]
|
||||
version: string
|
||||
}
|
||||
|
||||
async function getPackageDetails(dir: string): Promise<PackageDetails | null> {
|
||||
const packageJsonPath = path.join(dir, 'package.json')
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
return null
|
||||
}
|
||||
const packageJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'))
|
||||
if (packageJson.private) {
|
||||
return null
|
||||
}
|
||||
|
||||
const workspacePackages = await getAllWorkspacePackages()
|
||||
return {
|
||||
name: packageJson.name,
|
||||
dir,
|
||||
version: packageJson.version,
|
||||
localDeps: Object.keys(packageJson.dependencies ?? {}).filter((dep) =>
|
||||
workspacePackages.some((p) => p.name === dep)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllPackageDetails(): Promise<Record<string, PackageDetails>> {
|
||||
const dirs = readdirSync(join(REPO_ROOT, 'packages'))
|
||||
const details = await Promise.all(
|
||||
dirs.map((dir) => getPackageDetails(path.join(REPO_ROOT, 'packages', dir)))
|
||||
)
|
||||
const results = details.filter((x): x is PackageDetails => Boolean(x))
|
||||
|
||||
return Object.fromEntries(results.map((result) => [result.name, result]))
|
||||
}
|
||||
|
||||
export async function setAllVersions(version: string, options?: { stageChanges?: boolean }) {
|
||||
const packages = await getAllPackageDetails()
|
||||
for (const packageDetails of Object.values(packages)) {
|
||||
const manifest = JSON.parse(readFileSync(path.join(packageDetails.dir, 'package.json'), 'utf8'))
|
||||
manifest.version = version
|
||||
writeFileSync(
|
||||
path.join(packageDetails.dir, 'package.json'),
|
||||
JSON.stringify(manifest, null, '\t') + '\n'
|
||||
)
|
||||
}
|
||||
|
||||
await exec('yarn', ['refresh-assets', '--force'], { env: { ALLOW_REFRESH_ASSETS_CHANGES: '1' } })
|
||||
|
||||
const lernaJson = JSON.parse(readFileSync('lerna.json', 'utf8'))
|
||||
lernaJson.version = version
|
||||
writeFileSync('lerna.json', JSON.stringify(lernaJson, null, '\t') + '\n')
|
||||
|
||||
execSync('yarn')
|
||||
|
||||
if (options?.stageChanges) {
|
||||
await stageAllPackageJsonChanges()
|
||||
}
|
||||
}
|
||||
|
||||
async function stageAllPackageJsonChanges() {
|
||||
// stage the changes
|
||||
const packageJsonFilesToAdd = []
|
||||
for (const workspace of await getAllWorkspacePackages()) {
|
||||
if (workspace.relativePath.startsWith('packages/')) {
|
||||
packageJsonFilesToAdd.push(`${workspace.relativePath}/package.json`)
|
||||
}
|
||||
}
|
||||
const versionFilesToAdd = glob.sync('**/*/version.ts', {
|
||||
ignore: ['node_modules/**'],
|
||||
follow: false,
|
||||
})
|
||||
console.log('versionFilesToAdd', versionFilesToAdd)
|
||||
await exec('git', [
|
||||
'add',
|
||||
'--update',
|
||||
'lerna.json',
|
||||
...packageJsonFilesToAdd,
|
||||
...versionFilesToAdd,
|
||||
])
|
||||
}
|
||||
|
||||
function assertExists<T>(v: T | null | undefined): T {
|
||||
if (v === null || v === undefined) throw new Error('Expected value to exist')
|
||||
return v
|
||||
}
|
||||
|
||||
export async function getLatestTldrawVersionFromNpm({
|
||||
versionPrefix,
|
||||
}: { versionPrefix?: string } = {}) {
|
||||
if (!versionPrefix)
|
||||
return assertExists(parse((await exec('npm', ['show', 'tldraw', 'version'])).trim()))
|
||||
|
||||
const versions = (await exec('npm', ['show', 'tldraw@~' + versionPrefix, 'version'])).trim()
|
||||
if (versions.startsWith('tldraw')) {
|
||||
return assertExists(parse(versions.split('\n').pop()?.split(' ')[1].replaceAll("'", '')))
|
||||
}
|
||||
return assertExists(parse(versions))
|
||||
}
|
||||
|
||||
function topologicalSortPackages(packages: Record<string, PackageDetails>) {
|
||||
const sorted: PackageDetails[] = []
|
||||
const visited = new Set<string>()
|
||||
|
||||
function visit(packageName: string, path: string[]) {
|
||||
if (visited.has(packageName)) {
|
||||
return
|
||||
}
|
||||
visited.add(packageName)
|
||||
const packageDetails = packages[packageName]
|
||||
if (!packageDetails) {
|
||||
throw new Error(`Could not find package ${packageName}. path: ${path.join(' -> ')}`)
|
||||
}
|
||||
packageDetails.localDeps.forEach((dep) => visit(dep, [...path, dep]))
|
||||
sorted.push(packageDetails)
|
||||
}
|
||||
|
||||
Object.keys(packages).forEach((packageName) => visit(packageName, [packageName]))
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
export async function publish(distTag?: string) {
|
||||
// Authentication uses npm's trusted publisher OIDC flow. The publish job in
|
||||
// CI must grant `permissions: id-token: write` so yarn (>= 4.10, which we are
|
||||
// on via `packageManager`) can exchange the GitHub-issued OIDC token for a
|
||||
// short-lived publish token automatically.
|
||||
//
|
||||
// We invoke `yarn npm publish` rather than `npm publish` directly so that
|
||||
// yarn rewrites `workspace:*` dependency specifiers in the published
|
||||
// tarball into the concrete sibling versions. `npm publish` has no concept
|
||||
// of yarn's workspace protocol and would ship `"workspace:*"` literally,
|
||||
// breaking installs for any consumer outside this monorepo.
|
||||
// See https://docs.npmjs.com/trusted-publishers
|
||||
const packages = await getAllPackageDetails()
|
||||
|
||||
const publishOrder = topologicalSortPackages(packages)
|
||||
|
||||
// npm can take several minutes before a published version is readable. Waiting only
|
||||
// on a package's own dependencies keeps a newly visible package installable, without
|
||||
// making the run wait for every package's delay in turn.
|
||||
const readable = new Map<string, Promise<void>>()
|
||||
|
||||
for (const packageDetails of publishOrder) {
|
||||
await Promise.all(packageDetails.localDeps.map((dep) => readable.get(dep)))
|
||||
|
||||
const tag = distTag ?? parse(packageDetails.version)?.prerelease[0] ?? 'latest'
|
||||
nicelog(
|
||||
`Publishing ${packageDetails.name} with version ${packageDetails.version} under tag @${tag}`
|
||||
)
|
||||
|
||||
await retry(
|
||||
async () => {
|
||||
let output = ''
|
||||
const publishStart = Date.now()
|
||||
nicelog(
|
||||
`[publish] ${packageDetails.name}@${packageDetails.version} starting npm publish...`
|
||||
)
|
||||
try {
|
||||
await exec(
|
||||
`yarn`,
|
||||
[
|
||||
'npm',
|
||||
'publish',
|
||||
'--tag',
|
||||
String(tag),
|
||||
'--tolerate-republish',
|
||||
'--provenance',
|
||||
'--access',
|
||||
'public',
|
||||
],
|
||||
{
|
||||
pwd: packageDetails.dir,
|
||||
processStdoutLine: (line) => {
|
||||
output += line + '\n'
|
||||
nicelog(line)
|
||||
},
|
||||
processStderrLine: (line) => {
|
||||
output += line + '\n'
|
||||
nicelog(line)
|
||||
},
|
||||
}
|
||||
)
|
||||
} catch (e) {
|
||||
// A retry after a publish that actually landed is rejected as "published",
|
||||
// or as "staged" (409) while npm is still processing the first attempt.
|
||||
const lowerOutput = output.toLowerCase()
|
||||
if (
|
||||
lowerOutput.includes('cannot publish over the previously published versions') ||
|
||||
lowerOutput.includes('cannot publish over previously staged version')
|
||||
) {
|
||||
nicelog(
|
||||
`[publish] ${packageDetails.name}@${packageDetails.version} already published or staged, skipping`
|
||||
)
|
||||
return
|
||||
}
|
||||
throw e
|
||||
}
|
||||
const elapsed = ((Date.now() - publishStart) / 1000).toFixed(1)
|
||||
nicelog(
|
||||
`[publish] ${packageDetails.name}@${packageDetails.version} npm publish done (${elapsed}s)`
|
||||
)
|
||||
},
|
||||
{
|
||||
delay: 10_000,
|
||||
numAttempts: 5,
|
||||
}
|
||||
)
|
||||
|
||||
const whenReadable = waitUntilReadable(packageDetails)
|
||||
// Rejections surface when awaited below; without this, one arriving mid-loop would
|
||||
// kill the process in the middle of another package's publish.
|
||||
whenReadable.catch(() => {})
|
||||
readable.set(packageDetails.name, whenReadable)
|
||||
}
|
||||
|
||||
await Promise.all(readable.values())
|
||||
}
|
||||
|
||||
function waitUntilReadable(packageDetails: PackageDetails) {
|
||||
const url = `https://registry.npmjs.org/${packageDetails.name}/${packageDetails.version}`
|
||||
return retry(
|
||||
async ({ attempt, total }) => {
|
||||
const res = await fetch(url, { method: 'HEAD' })
|
||||
if (res.status >= 400) {
|
||||
nicelog(
|
||||
`[verify] ${packageDetails.name}@${packageDetails.version} not readable yet (${res.status}), attempt ${attempt + 1} of ${total}`
|
||||
)
|
||||
throw new Error(`Package not found: ${url} (${res.status})`)
|
||||
}
|
||||
nicelog(`[verify] ${packageDetails.name}@${packageDetails.version} is readable`)
|
||||
},
|
||||
{
|
||||
delay: 10_000,
|
||||
// 15 minutes; the slowest package took over 8 minutes in September 2026.
|
||||
numAttempts: 90,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function retry(
|
||||
fn: (args: { attempt: number; remaining: number; total: number }) => Promise<void>,
|
||||
opts: {
|
||||
numAttempts: number
|
||||
delay: number
|
||||
}
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let attempts = 0
|
||||
function attempt() {
|
||||
fn({ attempt: attempts, remaining: opts.numAttempts - attempts, total: opts.numAttempts })
|
||||
.then(resolve)
|
||||
.catch((err) => {
|
||||
attempts++
|
||||
if (attempts >= opts.numAttempts) {
|
||||
reject(err)
|
||||
} else {
|
||||
setTimeout(attempt, opts.delay)
|
||||
}
|
||||
})
|
||||
}
|
||||
attempt()
|
||||
})
|
||||
}
|
||||
|
||||
export async function publishProductionDocsAndExamplesAndBemo({
|
||||
gitRef = 'HEAD',
|
||||
}: { gitRef?: string } = {}) {
|
||||
await exec('git', ['push', 'origin', `${gitRef}:docs-production`, `--force`])
|
||||
await exec('git', ['push', 'origin', `${gitRef}:bemo-production`, `--force`])
|
||||
}
|
||||
|
||||
export async function triggerBumpVersionsWorkflow(ghToken: string) {
|
||||
const octokit = new Octokit({ auth: ghToken })
|
||||
await octokit.rest.actions.createWorkflowDispatch({
|
||||
owner: 'tldraw',
|
||||
repo: 'tldraw',
|
||||
workflow_id: 'bump-versions.yml',
|
||||
ref: 'main',
|
||||
})
|
||||
}
|
||||
19
internal/scripts/lib/skip-ci.ts
Normal file
19
internal/scripts/lib/skip-ci.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// GitHub skips both push- and pull_request-triggered workflows when one of these markers appears
|
||||
// in a commit message, in the positions documented at
|
||||
// https://docs.github.com/en/actions/managing-workflow-runs/skipping-workflow-runs
|
||||
// `[^\S\r\n]` rather than `\s`: eating the newlines around a marker on its own body line would
|
||||
// close the blank line separating subject from body, and `git commit --amend -m` would then fold
|
||||
// the whole first paragraph into the subject.
|
||||
const SKIP_CI_MARKER = /[^\S\r\n]*\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]/gi
|
||||
|
||||
/**
|
||||
* Remove GitHub's skip-ci markers from a commit message or PR title.
|
||||
*
|
||||
* Release-notes PRs carry `[skip ci]` in their title on purpose, so their squash-merge onto `main`
|
||||
* doesn't run the push workflows. The hotfix scripts cherry-pick that same commit onto a branch
|
||||
* whose push *must* trigger a workflow (the release branch's publish, or the hotfixes branch's
|
||||
* production build), so the marker has to go before the push.
|
||||
*/
|
||||
export function stripSkipCiMarkers(message: string): string {
|
||||
return message.replace(SKIP_CI_MARKER, '').trim()
|
||||
}
|
||||
42
internal/scripts/lib/sort-unions.ts
Normal file
42
internal/scripts/lib/sort-unions.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import path from 'path'
|
||||
import { namedTypes } from 'ast-types'
|
||||
import { glob } from 'glob'
|
||||
import { parse, print, visit } from 'recast'
|
||||
|
||||
export function sortUnions(tsbuildDir: string) {
|
||||
for (const file of glob.sync(path.join(tsbuildDir, '**/*.d.ts'))) {
|
||||
try {
|
||||
const code = parse(readFileSync(file, 'utf8'), {
|
||||
parser: require('recast/parsers/typescript'),
|
||||
})
|
||||
|
||||
visit(code, {
|
||||
visitTSUnionType(path) {
|
||||
this.traverse(path)
|
||||
const val = path.value as namedTypes.TSUnionType
|
||||
val.types = val.types.sort((a, b) => {
|
||||
const aText = print(a).code
|
||||
const bText = print(b).code
|
||||
return aText.localeCompare(bText)
|
||||
})
|
||||
return false
|
||||
},
|
||||
visitTSTypeLiteral(path) {
|
||||
this.traverse(path)
|
||||
const val = path.value as namedTypes.TSTypeLiteral
|
||||
val.members = val.members.sort((a, b) => {
|
||||
const aText = print(a).code
|
||||
const bText = print(b).code
|
||||
return aText.localeCompare(bText)
|
||||
})
|
||||
return false
|
||||
},
|
||||
})
|
||||
|
||||
writeFileSync(file, print(code).code)
|
||||
} catch (e) {
|
||||
throw new Error(`Error sorting unions in ${file}`, { cause: e })
|
||||
}
|
||||
}
|
||||
}
|
||||
80
internal/scripts/lib/types.ts
Normal file
80
internal/scripts/lib/types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { T } from '@tldraw/validate'
|
||||
|
||||
export const EXPORT_CONFIG_KEY = 'tldraw_template' as const
|
||||
|
||||
export const TemplateDeployConfig = T.object({
|
||||
// Where the live demo is hosted. The vercel-hosted demos deploy through vercel's own git
|
||||
// integration, so only cloudflare templates carry a `deploy` config.
|
||||
target: T.literalEnum('cloudflare'),
|
||||
// The hostname the demo is served from. Must match the custom domain route in wrangler.toml.
|
||||
host: T.string,
|
||||
})
|
||||
export type TemplateDeployConfig = T.TypeOf<typeof TemplateDeployConfig>
|
||||
|
||||
export const TemplateConfig = T.object({
|
||||
repo: T.string,
|
||||
cli: T.object({
|
||||
name: T.string,
|
||||
description: T.string,
|
||||
shortDescription: T.string.optional(),
|
||||
order: T.number.optional(),
|
||||
}).optional(),
|
||||
deploy: TemplateDeployConfig.optional(),
|
||||
scripts: T.dict(T.string, T.nullable(T.string)).optional(),
|
||||
})
|
||||
export type TemplateConfig = T.TypeOf<typeof TemplateConfig>
|
||||
|
||||
export const PRODUCT_CONFIG_KEY = 'tldraw_product' as const
|
||||
|
||||
/**
|
||||
* Commercial product metadata for a published package. The `stableId` identifies the commercial
|
||||
* component a package belongs to; it is referenced by legal in order forms and must never change,
|
||||
* even if the package is renamed or moved. Several packages can share one stable id. See
|
||||
* `internal/docs/product-stable-ids.md` for the full convention.
|
||||
*/
|
||||
export const ProductConfig = T.object({
|
||||
// Immutable commercial identifier, e.g. `tldraw:sdk-core`. Never changes on rename/refactor.
|
||||
stableId: T.string,
|
||||
// Customer-facing label, matching the wording used on the order form, e.g. 'Collaboration
|
||||
// module'. Unlike stableId this may be reworded; the id is what contracts reference.
|
||||
name: T.string,
|
||||
// `product` is a top-level order form line item; `feature` is an optional part of its parent
|
||||
// that can be licensed on its own, listed indented beneath it on the order form.
|
||||
type: T.literalEnum('product', 'feature'),
|
||||
// The component this one belongs to, e.g. commenting's parent is the collaboration module.
|
||||
// Components without a parent are top-level order form line items.
|
||||
parent: T.string.optional(),
|
||||
// Whether this component is separately licensed rather than part of the standard SDK offering.
|
||||
premium: T.boolean,
|
||||
// For premium components: the license key FLAGS bit (in packages/editor's LicenseManager) that
|
||||
// entitles a customer to this component.
|
||||
licenseFlag: T.string.optional(),
|
||||
})
|
||||
export type ProductConfig = T.TypeOf<typeof ProductConfig>
|
||||
|
||||
export const PackageJson = T.object({
|
||||
name: T.string,
|
||||
private: T.boolean.optional(),
|
||||
workspaces: T.arrayOf(T.string).optional(),
|
||||
[EXPORT_CONFIG_KEY]: TemplateConfig.optional(),
|
||||
[PRODUCT_CONFIG_KEY]: ProductConfig.optional(),
|
||||
license: T.string.optional(),
|
||||
description: T.string.optional(),
|
||||
scripts: T.dict(T.string, T.nullable(T.string)).optional(),
|
||||
dependencies: T.dict(T.string, T.string).optional(),
|
||||
devDependencies: T.dict(T.string, T.string).optional(),
|
||||
peerDependencies: T.dict(T.string, T.string).optional(),
|
||||
optionalDependencies: T.dict(T.string, T.string).optional(),
|
||||
engines: T.dict(T.string, T.string).optional(),
|
||||
packageManager: T.string.optional(),
|
||||
}).allowUnknownProperties()
|
||||
export type PackageJson = T.TypeOf<typeof PackageJson>
|
||||
|
||||
export const TsConfigJson = T.object({
|
||||
references: T.arrayOf(
|
||||
T.object({
|
||||
path: T.string,
|
||||
})
|
||||
).optional(),
|
||||
}).allowUnknownProperties()
|
||||
export type TsConfigJson = T.TypeOf<typeof TsConfigJson>
|
||||
98
internal/scripts/lib/upload-static-assets.ts
Normal file
98
internal/scripts/lib/upload-static-assets.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
|
||||
import mime from 'mime'
|
||||
import { exec } from './exec'
|
||||
import { makeEnv } from './makeEnv'
|
||||
|
||||
const env = makeEnv(['R2_ACCESS_KEY_ID', 'R2_ACCESS_KEY_SECRET'])
|
||||
|
||||
const R2_URL = 'https://c34edc4e76350954b63adebde86d5eb1.r2.cloudflarestorage.com'
|
||||
const R2_BUCKET = 'cdn'
|
||||
const ASSETS_FOLDER = './packages/assets'
|
||||
|
||||
// How many files to upload to the CDN at once.
|
||||
const UPLOAD_CONCURRENCY = 16
|
||||
|
||||
const R2 = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: R2_URL,
|
||||
credentials: {
|
||||
accessKeyId: env.R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.R2_ACCESS_KEY_SECRET,
|
||||
},
|
||||
})
|
||||
|
||||
async function uploadFile(key: string, fullPath: string) {
|
||||
const fileStream = fs.createReadStream(fullPath)
|
||||
const contentType = mime.getType(fullPath) ?? 'application/octet-stream'
|
||||
const uploadParams = {
|
||||
Bucket: R2_BUCKET,
|
||||
Key: key,
|
||||
Body: fileStream,
|
||||
ContentType: contentType,
|
||||
// these assets will never change, so we can cache them forever:
|
||||
CacheControl: 'public, max-age=31536000, immutable',
|
||||
}
|
||||
await R2.send(new PutObjectCommand(uploadParams))
|
||||
process.stdout.write(` • ${key} ✔️\n`)
|
||||
}
|
||||
|
||||
function collectFiles(
|
||||
prefix: string,
|
||||
directoryPath: string
|
||||
): Array<{ key: string; fullPath: string }> {
|
||||
const files: Array<{ key: string; fullPath: string }> = []
|
||||
|
||||
for (const entry of fs.readdirSync(directoryPath, { withFileTypes: true })) {
|
||||
const fullPath = path.join(directoryPath, entry.name)
|
||||
const key = path.join(prefix, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...collectFiles(key, fullPath))
|
||||
} else if (entry.isFile()) {
|
||||
files.push({ key, fullPath })
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
export async function uploadStaticAssets(version: string) {
|
||||
try {
|
||||
await exec('yarn', ['refresh-assets'], { env: { ALLOW_REFRESH_ASSETS_CHANGES: '1' } })
|
||||
|
||||
const entries = fs.readdirSync(ASSETS_FOLDER, { withFileTypes: true })
|
||||
|
||||
// Gather every file across all asset folders up front so we can upload
|
||||
// them with bounded concurrency instead of one at a time.
|
||||
const files: Array<{ key: string; fullPath: string }> = []
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue
|
||||
if (!entry.isDirectory()) continue
|
||||
|
||||
const folderName = entry.name
|
||||
files.push(...collectFiles(`${version}/${folderName}`, `${ASSETS_FOLDER}/${folderName}`))
|
||||
}
|
||||
|
||||
console.log(`Uploading ${files.length} static assets to CDN...`)
|
||||
|
||||
// Upload files in parallel, keeping at most UPLOAD_CONCURRENCY requests in
|
||||
// flight at a time. Workers pull from a shared queue until it's empty.
|
||||
let next = 0
|
||||
async function worker() {
|
||||
while (next < files.length) {
|
||||
const { key, fullPath } = files[next++]
|
||||
await uploadFile(key, fullPath)
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(UPLOAD_CONCURRENCY, files.length) }, () => worker())
|
||||
)
|
||||
|
||||
console.log('Uploaded!')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user