Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 34 additions & 25 deletions src/lib/amplitude-sample-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,31 +71,6 @@ const CHART_SPECS: Record<string, ChartSpec> = {
"wsl2-virtioproxy",
],
},
commands: {
title: "DDEV Commands",
base: 900000,
decay: 0.72,
labels: [
"exec",
"mysql",
"describe",
"wp",
"custom-command",
"status",
"composer",
"php",
"start",
"artisan",
"xdebug",
"typo3",
"npm",
"restart",
"stop",
"ssh",
"craft",
"magento",
],
},
cmsProjectTypes: {
title: "CMS Project Types",
base: 5000,
Expand Down Expand Up @@ -224,6 +199,34 @@ const CHART_SPECS: Record<string, ChartSpec> = {

type PropertySpec = { labels: string[]; base: number; decay: number }

// Sample fallback for getCommandUsage(): documented ddev commands in roughly
// their real popularity order (Totals). Not a saved chart, so it lives outside
// CHART_SPECS.
const COMMAND_USAGE_SPEC: PropertySpec = {
base: 900000,
decay: 0.72,
labels: [
"exec",
"drush",
"mysql",
"describe",
"wp",
"composer",
"php",
"start",
"artisan",
"xdebug",
"typo3",
"npm",
"restart",
"stop",
"ssh",
"craft",
"magento",
"import-db",
],
}

// Keyed by the property names passed to getProjectPropertyBreakdown().
const PROPERTY_SPECS: Record<string, PropertySpec> = {
"PHP Version": {
Expand Down Expand Up @@ -267,6 +270,12 @@ export function getSampleChart(key: string): AmplitudeChart | null {
}
}

// Sample breakdown of command usage (see getCommandUsage).
export function getSampleCommandUsage(): PropertyBreakdown {
const spec = COMMAND_USAGE_SPEC
return toBreakdown(decayingCounts(spec.labels, spec.base, spec.decay))
}

// Sample breakdown for a "Project" property name, or null if unknown.
export function getSampleProjectProperty(
propertyName: string
Expand Down
161 changes: 156 additions & 5 deletions src/lib/amplitude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import dotenv from "dotenv"
import { getCache, putCache } from "./api"
import {
getSampleChart,
getSampleCommandUsage,
getSampleMonthlyUserHistory,
getSampleProjectProperty,
} from "./amplitude-sample-data"
Expand All @@ -15,7 +16,6 @@ dotenv.config()
// Saved chart IDs behind each usage-stats section (see https://app.amplitude.com/analytics/ddev/home)
export const AMPLITUDE_CHARTS = {
environment: "vkdc4w6s",
commands: "bzk6dz2u",
cmsProjectTypes: "jz0wqx1w",
macosDockerProviders: "2orxojlh",
wsl2DockerProviders: "5o615zus",
Expand All @@ -35,8 +35,6 @@ export const AMPLITUDE_SHARE_LINKS: Partial<
> = {
environment:
"https://app.amplitude.com/analytics/share/1238aca77448459aac7473f2fedbe109",
commands:
"https://app.amplitude.com/analytics/share/f4e48d8678134561ae034f9a020faab4",
cmsProjectTypes:
"https://app.amplitude.com/analytics/share/0619ab47f9cd433cb14bdea4b4aab3e2",
macosDockerProviders:
Expand Down Expand Up @@ -496,11 +494,164 @@ async function getProjectPropertyBreakdown(

const breakdown: PropertyBreakdown = {
rows: counts
.map((row) => ({
.map((row: { label: string; count: number }) => ({
...row,
percent: grandTotal ? (row.count / grandTotal) * 100 : 0,
}))
.sort((a, b) => b.count - a.count),
.sort((a: PercentageRow, b: PercentageRow) => b.count - a.count),
}

putCache(cacheFilename, JSON.stringify(breakdown))

return breakdown
}

// Built-in ddev commands documented at
// https://docs.ddev.com/en/stable/users/usage/commands/. The command-usage
// breakdown filters Amplitude's raw "Command Name" values to this set so it
// shows only real ddev subcommands. Everything else Amplitude records under
// that property is deliberately dropped: the "custom-command" aggregate and
// project-specific custom command names, second-level sub-args tracked as
// their own name (e.g. "pantheon"/"acquia" from `ddev pull pantheon`), and
// undocumented/legacy names. Keep sorted for easy scanning.
export const DDEV_COMMANDS: readonly string[] = [
"add-on",
"aliases",
"artisan",
"asterios",
"auth",
"blackfire",
"cake",
"clean",
"composer",
"config",
"console",
"craft",
"dbeaver",
"delete",
"describe",
"dotenv",
"dr",
"drush",
"exec",
"export-db",
"heidisql",
"help",
"hostname",
"import-db",
"import-files",
"launch",
"list",
"logs",
"magento",
"mailpit",
"mariadb",
"mutagen",
"mysql",
"npm",
"npx",
"php",
"poweroff",
"psql",
"pull",
"push",
"querious",
"restart",
"sake",
"self-upgrade",
"sequelace",
"share",
"snapshot",
"spark",
"ssh",
"start",
"stop",
"tableplus",
"tui",
"typo3",
"utility",
"version",
"wp",
"xdebug",
"xhgui",
"xhprof",
"yarn",
]

/**
* Gets total invocations per ddev command over the last 7 days, via an ad hoc
* Events Segmentation query on the "Command" event grouped by "Command Name",
* then filtered to DDEV_COMMANDS. Done in code (rather than an Amplitude saved
* chart's allow-list) so the filter is version-controlled and reviewable, and
* stays a single source of truth. Percentages are relative to the displayed
* (documented) commands only.
*/
export async function getCommandUsage(): Promise<PropertyBreakdown | null> {
const cacheFilename = "amplitude-command-usage.json"
const cachedData = getCache(cacheFilename)

if (cachedData) {
return cachedData
}

if (!amplitudeCredentialsSet) {
return useSampleData ? getSampleCommandUsage() : null
}

const end = new Date()
const start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000)
const toYyyymmdd = (date: Date) =>
date.toISOString().slice(0, 10).replace(/-/g, "")

const params = new URLSearchParams({
e: JSON.stringify({
event_type: "Command",
group_by: [{ type: "event", value: "Command Name" }],
}),
start: toYyyymmdd(start),
end: toYyyymmdd(end),
i: "1",
m: "totals",
limit: "1000",
})

const response = await fetch(
`https://amplitude.com/api/2/events/segmentation?${params.toString()}`,
{
headers: { Authorization: authHeader() },
}
)

if (!response.ok) {
throw new Error(
`Amplitude segmentation request failed: HTTP ${response.status}`
)
}

const { data } = await response.json()
const allowed = new Set(DDEV_COMMANDS)
// Inline group_by returns seriesLabels as [groupIndex, label] pairs.
const counts = data.seriesLabels
.map((entry: string | [number, string], index: number) => ({
label: Array.isArray(entry) ? entry[1] : entry,
count: data.seriesCollapsed[index]?.[0]?.value ?? 0,
}))
.filter(
(row: { label: string; count: number }) =>
allowed.has(row.label) && row.count > 0
)
const grandTotal = counts.reduce(
(sum: number, row: { count: number }) => sum + row.count,
0
)

const breakdown: PropertyBreakdown = {
rows: counts
.map((row: { label: string; count: number }) => ({
...row,
percent: grandTotal ? (row.count / grandTotal) * 100 : 0,
}))
.sort((a: PercentageRow, b: PercentageRow) => b.count - a.count),
}

putCache(cacheFilename, JSON.stringify(breakdown))
Expand Down
54 changes: 36 additions & 18 deletions src/pages/usage-stats.astro
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
AMPLITUDE_CHARTS,
AMPLITUDE_SHARE_LINKS,
getAmplitudeChart,
getCommandUsage,
getDatabaseTypes,
getMonthlyUserHistory,
getPhpVersions,
Expand Down Expand Up @@ -44,7 +45,7 @@ const linkClass = "underline dark:text-white"
// for properties/filters no saved chart covers correctly (see each
// function's own comment in lib/amplitude.ts for why).
const environment = await getAmplitudeChart(AMPLITUDE_CHARTS.environment)
const commands = await getAmplitudeChart(AMPLITUDE_CHARTS.commands)
const commandUsage = await getCommandUsage()
const cmsProjectTypes = await getAmplitudeChart(
AMPLITUDE_CHARTS.cmsProjectTypes
)
Expand Down Expand Up @@ -181,30 +182,47 @@ const sponsorshipGoal = sponsorshipData?.current_goal
}

{
commands && (
commandUsage && (
<section class="my-12">
<SectionHeading title="DDEV Commands" />
<p class="my-4 dark:text-slate-300">
The most popular <code>ddev</code> subcommands, excluding
<code>drush</code>, which dwarfs everything else and is likely
run in automation.
Total invocations of built-in <code>ddev</code> commands over the
past week. Counts for commands like <code>exec</code> and{" "}
<code>drush</code> are inflated by scripting and automated
workflows. Expand below for the full list of documented commands
with usage.
</p>
<UsageStatBars
rows={toPercentageRows(commands, "segmentation")}
limit={18}
rows={commandUsage.rows}
limit={20}
valueFormat="count"
/>
{AMPLITUDE_SHARE_LINKS.commands && (
<p class="mt-2 text-sm">
<a
href={AMPLITUDE_SHARE_LINKS.commands}
target="_blank"
class={linkClass}
>
Live values for DDEV commands
</a>
</p>
)}
<details class="mt-4">
<summary class="cursor-pointer text-sm font-mono dark:text-slate-300">
Show all {commandUsage.rows.length} documented commands with
usage
</summary>
<div class="overflow-x-auto mt-3">
<table class="w-full font-mono text-sm">
<thead>
<tr class="border-b dark:border-slate-600 text-left dark:text-white">
<th class="py-2 pr-4">Command</th>
<th class="py-2 text-right">Invocations</th>
</tr>
</thead>
<tbody>
{commandUsage.rows.map((row) => (
<tr class="border-b border-gray-100 dark:border-slate-700 dark:text-slate-200">
<td class="py-1.5 pr-4">{row.label}</td>
<td class="py-1.5 text-right">
{row.count.toLocaleString("en-US")}
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</section>
)
}
Expand Down