-
Notifications
You must be signed in to change notification settings - Fork 561
feat(usage): break usage down by request type or SDK #8343
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
34 commits
Select commit
Hold shift + click to select a range
870231d
feat(usage): derive a usage breakdown from the response
talissoncosta 0abe57b
feat(usage): add the breakdown section and its rows
talissoncosta 5181301
feat(usage): fetch per-project and per-environment totals
talissoncosta b4ad092
feat(usage): show the breakdown on the dashboard
talissoncosta 96b2844
docs(usage): cover the breakdown states in Storybook
talissoncosta 63fdbce
fix(usage): correct the scoped breakdown and share the bar
talissoncosta 31795d5
refactor(shared): name the bar primitive ValueBar
talissoncosta 5ae25b1
fix(a11y): report plan usage as a meter, not a progress bar
talissoncosta 85238e6
refactor(usage): drop the per-row colours
talissoncosta 0141cb5
refactor(usage): group the breakdown's sub-components
talissoncosta a2f4729
fix(usage): label the dimension select, and wait for the scope list
talissoncosta 8f9c72f
chore(usage): trim comments that repeat themselves
talissoncosta cb27695
refactor(usage): move the breakdown in with the page it belongs to
talissoncosta d582724
test(usage): use the shared usage fixtures in the breakdown tests
talissoncosta 724ef37
fix(usage): let a failed scope answer, and drop stale ones on a switch
talissoncosta 37fc660
refactor(usage): move the breakdown's data into a hook
talissoncosta befc923
refactor(usage): drop the View suffix, and let the page own the fetching
talissoncosta a565403
feat(usage): colour the request-type rows
talissoncosta e102c70
chore(usage): fix a stale comment and drop three that say nothing
talissoncosta 271dea5
chore(usage): cut the ValueBar docblock to one line
talissoncosta 9b4e605
chore(usage): trim ScopeTotal's comments to the parts that prevent a bug
talissoncosta 8c88cee
chore(usage): take the explanations out of the breakdown too
talissoncosta b39d74b
chore(usage): drop the last two page comments
talissoncosta 36bc86e
fix(usage): the environment breakdown asked for the wrong id
talissoncosta 85e8174
fix(usage): a scope answers when it stops fetching, not when it has a…
talissoncosta 2eb0b66
refactor(usage): give each breakdown sub-component its own folder
talissoncosta 3a49f8f
feat(usage): hold the project and environment dimensions back
talissoncosta db92b04
fix(usage): let a breakdown row survive a narrow viewport
talissoncosta 5fd0a84
refactor(usage): stop exporting what nothing imports
talissoncosta 9b514d4
refactor(usage): keep the sub-component barrels to what is public
talissoncosta 3ec67c5
fix(usage): make the breakdown shares add up to 100
talissoncosta 246293a
fix(usage): keep a tiny contributor's bar visible
talissoncosta c6d602a
feat(usage): say what the breakdown is showing
talissoncosta 3f68637
fix(usage): let the first breakdown row lose its top border
talissoncosta File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
101 changes: 101 additions & 0 deletions
101
frontend/documentation/components/UsageBreakdown.stories.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| import type { Meta, StoryObj } from 'storybook' | ||
| import { | ||
| colorChart1, | ||
| colorChart2, | ||
| colorChart3, | ||
| colorChart4, | ||
| } from 'common/theme/tokens' | ||
| import UsageBreakdown from 'components/pages/usage/components/UsageBreakdown' | ||
|
|
||
| const meta: Meta<typeof UsageBreakdown> = { | ||
| args: { onChangeDimension: () => {} }, | ||
| component: UsageBreakdown, | ||
| title: 'Pages/Usage Dashboard/Components/UsageBreakdown', | ||
| } | ||
| export default meta | ||
|
|
||
| type Story = StoryObj<typeof UsageBreakdown> | ||
|
|
||
| export const ByRequestType: Story = { | ||
| args: { | ||
| dimension: 'request-type', | ||
| rows: [ | ||
| { | ||
| colour: colorChart4, | ||
| key: 'environment_document', | ||
| label: 'Environment Document', | ||
| value: 5_240_000, | ||
| }, | ||
| { | ||
| colour: colorChart3, | ||
| key: 'identities', | ||
| label: 'Identities', | ||
| value: 1_910_000, | ||
| }, | ||
| { colour: colorChart1, key: 'flags', label: 'Flags', value: 730_000 }, | ||
| { colour: colorChart2, key: 'traits', label: 'Traits', value: 120_000 }, | ||
| ], | ||
| }, | ||
| } | ||
|
|
||
| /** One row dwarfing the rest is the common shape, so the bars must still read. */ | ||
| export const OneDominantRow: Story = { | ||
| args: { | ||
| dimension: 'request-type', | ||
| rows: [ | ||
| { colour: colorChart1, key: 'flags', label: 'Flags', value: 8_900_000 }, | ||
| { | ||
| colour: colorChart3, | ||
| key: 'identities', | ||
| label: 'Identities', | ||
| value: 41_000, | ||
| }, | ||
| { colour: colorChart2, key: 'traits', label: 'Traits', value: 9_000 }, | ||
| ], | ||
| }, | ||
| } | ||
|
|
||
| export const BySdk: Story = { | ||
| args: { | ||
| dimension: 'sdk', | ||
| rows: [ | ||
| { | ||
| key: 'flagsmith-python-3-9-1', | ||
| label: 'flagsmith-python/3.9.1', | ||
| value: 3_100_000, | ||
| }, | ||
| { | ||
| key: 'flagsmith-java-7-2-0', | ||
| label: 'flagsmith-java/7.2.0', | ||
| value: 2_450_000, | ||
| }, | ||
| { | ||
| key: 'flagsmith-nodejs-5-0-4', | ||
| label: 'flagsmith-nodejs/5.0.4', | ||
| value: 980_000, | ||
| }, | ||
| { key: 'unknown', label: 'Unknown', value: 210_000 }, | ||
| ], | ||
| }, | ||
| } | ||
|
|
||
| /** The section states what it is showing, so a filter is never silently applied. */ | ||
| export const ScopedToAProject: Story = { | ||
| args: { | ||
| dimension: 'request-type', | ||
| rows: [ | ||
| { colour: colorChart1, key: 'flags', label: 'Flags', value: 620_000 }, | ||
| { | ||
| colour: colorChart3, | ||
| key: 'identities', | ||
| label: 'Identities', | ||
| value: 180_000, | ||
| }, | ||
| ], | ||
| scope: 'Checkout · Last 30 days', | ||
| }, | ||
| } | ||
|
|
||
| export const NoUsageRecorded: Story = { | ||
| args: { dimension: 'request-type', rows: [] }, | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
3 changes: 3 additions & 0 deletions
3
frontend/web/components/pages/usage/components/UsageBreakdown/UsageBreakdown.scss
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| .usage-breakdown__dimension { | ||
| min-width: 210px; | ||
| } |
49 changes: 49 additions & 0 deletions
49
frontend/web/components/pages/usage/components/UsageBreakdown/UsageBreakdown.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { FC } from 'react' | ||
| import FieldLabel from 'components/base/forms/FieldLabel' | ||
| import List from './components/List' | ||
| import { BREAKDOWN_DIMENSIONS, BreakdownDimension, BreakdownRow } from './utils' | ||
| import './UsageBreakdown.scss' | ||
|
|
||
| export type UsageBreakdownProps = { | ||
| dimension: BreakdownDimension | ||
| onChangeDimension: (dimension: BreakdownDimension) => void | ||
| rows: BreakdownRow[] | ||
| scope?: string | ||
| } | ||
|
|
||
| type DimensionOption = (typeof BREAKDOWN_DIMENSIONS)[number] | ||
|
|
||
| const UsageBreakdown: FC<UsageBreakdownProps> = ({ | ||
| dimension, | ||
| onChangeDimension, | ||
| rows, | ||
| scope, | ||
| }) => ( | ||
| <div className='p-4 mt-3 border border-default rounded-lg bg-surface-default'> | ||
| <div className='d-flex align-items-end justify-content-between gap-3 mb-3'> | ||
| <div> | ||
| <strong>Where the usage came from</strong> | ||
| {scope && <div className='fs-captionSmall text-secondary'>{scope}</div>} | ||
| </div> | ||
| <div className='usage-breakdown__dimension'> | ||
| <FieldLabel htmlFor='usage-breakdown-dimension'> | ||
| Break down by | ||
| </FieldLabel> | ||
| <Select | ||
| inputId='usage-breakdown-dimension' | ||
| onChange={(option: DimensionOption) => | ||
| onChangeDimension(option.value) | ||
| } | ||
| value={BREAKDOWN_DIMENSIONS.find( | ||
| (option) => option.value === dimension, | ||
| )} | ||
| options={BREAKDOWN_DIMENSIONS} | ||
| /> | ||
| </div> | ||
| </div> | ||
|
|
||
| <List rows={rows} /> | ||
| </div> | ||
| ) | ||
|
|
||
| export default UsageBreakdown | ||
139 changes: 139 additions & 0 deletions
139
frontend/web/components/pages/usage/components/UsageBreakdown/__tests__/utils.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| import { colorChart1 } from 'common/theme/tokens' | ||
| import { | ||
| usageEvent, | ||
| usageResponse, | ||
| } from 'components/pages/usage/__tests__/fixtures' | ||
| import { | ||
| byRequestType, | ||
| barPercent, | ||
| bySdk, | ||
| sharesOf, | ||
| totalOf, | ||
| } from 'components/pages/usage/components/UsageBreakdown/utils' | ||
|
|
||
| describe('UsageBreakdown utils', () => { | ||
| describe('totalOf', () => { | ||
| it('counts every billable type', () => { | ||
| expect( | ||
| totalOf( | ||
| usageEvent({ | ||
| environment_document: 1, | ||
| flags: 10, | ||
| identities: 5, | ||
| traits: 2, | ||
| }), | ||
| ), | ||
| ).toBe(18) | ||
| }) | ||
| }) | ||
|
|
||
| describe('byRequestType', () => { | ||
| it('carries the colour each type has always had on the usage page', () => { | ||
| const result = byRequestType(usageResponse([usageEvent({ flags: 1 })])) | ||
|
|
||
| expect(result[0].colour).toBe(colorChart1) | ||
| }) | ||
|
|
||
| it('sums each type across every day, biggest first', () => { | ||
| const result = byRequestType( | ||
| usageResponse([ | ||
| usageEvent({ flags: 10, identities: 2 }), | ||
| usageEvent({ flags: 5, traits: 20 }), | ||
| ]), | ||
| ) | ||
|
|
||
| expect( | ||
| result.map(({ key, label, value }) => ({ key, label, value })), | ||
| ).toEqual([ | ||
| { key: 'traits', label: 'Traits', value: 20 }, | ||
| { key: 'flags', label: 'Flags', value: 15 }, | ||
| { key: 'identities', label: 'Identities', value: 2 }, | ||
| ]) | ||
| }) | ||
|
|
||
| it('drops types with no usage rather than showing empty rows', () => { | ||
| const result = byRequestType(usageResponse([usageEvent({ flags: 3 })])) | ||
|
|
||
| expect(result.map(({ label, value }) => ({ label, value }))).toEqual([ | ||
| { label: 'Flags', value: 3 }, | ||
| ]) | ||
| }) | ||
|
|
||
| it('returns nothing when there is no data', () => { | ||
| expect(byRequestType(undefined)).toEqual([]) | ||
| expect(byRequestType(usageResponse([]))).toEqual([]) | ||
| }) | ||
| }) | ||
|
|
||
| describe('bySdk', () => { | ||
| it('groups by user agent, biggest first', () => { | ||
| const result = bySdk( | ||
| usageResponse([ | ||
| usageEvent({ flags: 10, labels: { user_agent: 'python/3.1.0' } }), | ||
| usageEvent({ flags: 4, labels: { user_agent: 'java/2.0.0' } }), | ||
| usageEvent({ identities: 5, labels: { user_agent: 'python/3.1.0' } }), | ||
| ]), | ||
| ) | ||
|
|
||
| expect(result.map(({ label, value }) => ({ label, value }))).toEqual([ | ||
| { label: 'python/3.1.0', value: 15 }, | ||
| { label: 'java/2.0.0', value: 4 }, | ||
| ]) | ||
| }) | ||
|
|
||
| // Older events predate user-agent capture. Dropping them would make the | ||
| // rows disagree with the total on the meter above. | ||
| it('keeps unattributed usage rather than dropping it', () => { | ||
| const result = bySdk( | ||
| usageResponse([ | ||
| usageEvent({ flags: 10, labels: { user_agent: null } }), | ||
| usageEvent({ flags: 4, labels: { user_agent: 'go/1.0.0' } }), | ||
| ]), | ||
| ) | ||
|
|
||
| expect(result.map(({ label, value }) => ({ label, value }))).toEqual([ | ||
| { label: 'Unknown', value: 10 }, | ||
| { label: 'go/1.0.0', value: 4 }, | ||
| ]) | ||
| }) | ||
|
|
||
| it('returns nothing when there is no data', () => { | ||
| expect(bySdk(undefined)).toEqual([]) | ||
| }) | ||
| }) | ||
|
|
||
| describe('sharesOf', () => { | ||
| it('adds up to 100 when the split does not divide evenly', () => { | ||
| const shares = sharesOf([1, 1, 1]) | ||
|
|
||
| expect(shares.reduce((sum, share) => sum + share, 0)).toBe(100) | ||
| expect(shares).toEqual([34, 33, 33]) | ||
| }) | ||
|
|
||
| it('gives the spare points to the largest remainders', () => { | ||
| const shares = sharesOf([5, 3, 1]) | ||
|
|
||
| expect(shares.reduce((sum, share) => sum + share, 0)).toBe(100) | ||
| }) | ||
|
|
||
| it('reports zero rather than NaN when nothing was used', () => { | ||
| expect(sharesOf([0, 0])).toEqual([0, 0]) | ||
| }) | ||
| }) | ||
|
|
||
| describe('barPercent', () => { | ||
| it('keeps a tiny contributor visible rather than rounding it away', () => { | ||
| expect(barPercent(9_000, 8_900_000)).toBe(1) | ||
| }) | ||
|
|
||
| it('scales to the largest row', () => { | ||
| expect(barPercent(4_450_000, 8_900_000)).toBe(50) | ||
| expect(barPercent(8_900_000, 8_900_000)).toBe(100) | ||
| }) | ||
|
|
||
| it('draws nothing when there is nothing to draw', () => { | ||
| expect(barPercent(0, 8_900_000)).toBe(0) | ||
| expect(barPercent(10, 0)).toBe(0) | ||
| }) | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.