-
Notifications
You must be signed in to change notification settings - Fork 5
new guardian subscription model #3381
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
Open
johnduffell
wants to merge
24
commits into
main
Choose a base branch
from
jd/gu-subscription-model
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+4,042
−1,834
Open
Changes from 5 commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
54954c5
new guardian subscription model
johnduffell 9e2a549
add readme for the guardian subscription model
johnduffell 1526617
break down guardian subscription builder into smaller files
johnduffell 9d085e2
readme tidyup
johnduffell faa4ef4
better json/js highlighting in readme
johnduffell 3c0dbaa
Merge branch 'main' into jd/gu-subscription-model
johnduffell 2d88c72
merge conflicts manual resolution
johnduffell de3801b
better naming - pr feedback
johnduffell edd32a3
another better name
johnduffell 8894494
improve type safety of product*ids and tidy up types
johnduffell 03d68dc
simplify rate plan builders
johnduffell eb63237
parse zuora catalog correctly in tests
johnduffell 3c10dbe
pr feedback mainly to tidy up subscription filter
johnduffell 6d92101
Merge branch 'main' into jd/gu-subscription-model
johnduffell 4b857aa
make more tests type check correctly
johnduffell 8701f29
Merge branch 'main' into jd/gu-subscription-model
johnduffell 14004bc
remove unneeded lint exceptions
johnduffell 412d539
fix test that used badly formed product ids
johnduffell cd1b1d3
improve comments and naming
johnduffell 561380e
various pr feedback
johnduffell 18b6e12
Merge branch 'main' into jd/gu-subscription-model
johnduffell ad8572a
make guardianSubscriptionParser decision between guardian or zuora ra…
johnduffell 60c107b
add tests for mapFunctions
johnduffell 2ab26f4
extract separate module for guardian-catalog
johnduffell 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
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
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
30 changes: 30 additions & 0 deletions
30
handlers/product-switch-api/runManual/realSusbcriptions/README.md
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,30 @@ | ||
| # Redacted real subscription testing | ||
|
|
||
| This lets us test the parser on redacted subscriptions that have been seen in PROD in recent days. | ||
|
|
||
| It gives us greater confidence that a change will work in real life without breaking within a day or two. | ||
|
|
||
| ## GDPR and redaction | ||
|
|
||
| Although subscriptions don't have much sensitive data, we still need to be careful to look after it. | ||
|
|
||
| Risks of using real data are that it could be kept for longer than the retention period, or accidentally | ||
| committed to a repo. | ||
|
|
||
| - automatically replaces identifiers with arbitrary ids before storing them in the files. | ||
| - data should be stored outside of a git root | ||
| - tests are not run in CI so nothing could be logged publicly | ||
|
|
||
| ## How to use | ||
|
|
||
| ### Download and redact the data | ||
| 1. get AWS credentials | ||
| 2. set a suitable location in config.ts - to further reduce the risk of publication don't put it below a git root | ||
| 3. run downloadRealSubscriptions.ts (this takes up to a minute) | ||
|
|
||
| now the location specified will be populated with redacted json files for all subscriptions found in the log files | ||
|
|
||
| ### Run the test | ||
| 1. Rename testRealSubscriptions.ts to testRealSubscriptions.test.ts so jest can run it | ||
| 1. run the test from intellij | ||
| 1. rename the test back before committing to avoid it failing in CI | ||
7 changes: 7 additions & 0 deletions
7
handlers/product-switch-api/runManual/realSusbcriptions/config.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,7 @@ | ||
| /** | ||
| * to avoid accidental commits, fill in a suitable location that's outside of any repo | ||
| */ | ||
| export const subscriptionsDir = | ||
| '/Users/john_duffell/Downloads/subscriptionsJson/PROD/'; | ||
|
|
||
| export const logGroupName = '/aws/lambda/product-switch-api-PROD'; |
242 changes: 242 additions & 0 deletions
242
handlers/product-switch-api/runManual/realSusbcriptions/downloadRealSubscriptions.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,242 @@ | ||
| import { existsSync, mkdirSync, writeFileSync } from 'fs'; | ||
| import { join } from 'path'; | ||
| import type { GetQueryResultsCommandOutput } from '@aws-sdk/client-cloudwatch-logs'; | ||
| import { | ||
| CloudWatchLogsClient, | ||
| GetQueryResultsCommand, | ||
| StartQueryCommand, | ||
| } from '@aws-sdk/client-cloudwatch-logs'; | ||
| import { fromIni } from '@aws-sdk/credential-providers'; | ||
| import { logGroupName, subscriptionsDir } from './config'; | ||
|
|
||
| /* | ||
| * this script is used to prepare for running testRealSubscriptions.ts. It | ||
| * | ||
| * - downloads all the subscriptions used by the lambda over the past two weeks, | ||
| * - redacts the relevant ids and numbers, and | ||
| * - writes them as json files to the directory specified in config.ts | ||
| * | ||
| * First prepare AWS credentials, then run this script. | ||
| */ | ||
|
|
||
| function randomSubscription(): string { | ||
| return 'A-S999' + Math.floor(Math.random() * 100000); | ||
| } | ||
|
|
||
| function randomHex32(): string { | ||
| const chars = '0123456789abcdef'; | ||
| return ( | ||
| '999' + | ||
| Array.from( | ||
| { length: 29 }, | ||
| () => chars[Math.floor(Math.random() * chars.length)], | ||
| ).join('') | ||
| ); | ||
| } | ||
|
|
||
| function randomChargeNum(): string { | ||
| return 'C-999' + Math.floor(Math.random() * 100000); | ||
| } | ||
|
|
||
| function randomAccount(): string { | ||
| return 'A999' + Math.floor(Math.random() * 100000); | ||
| } | ||
|
|
||
| interface RatePlanCharge { | ||
| id?: string; | ||
| number?: string; | ||
| [key: string]: unknown; | ||
| } | ||
|
|
||
| interface RatePlan { | ||
| id?: string; | ||
| ratePlanCharges?: RatePlanCharge[]; | ||
| [key: string]: unknown; | ||
| } | ||
|
|
||
| interface RedactableSubscription { | ||
| id?: string; | ||
| accountNumber?: string; | ||
| subscriptionNumber?: string; | ||
| ratePlans?: RatePlan[]; | ||
| [key: string]: unknown; | ||
| } | ||
|
|
||
| function redactSubscription(obj: unknown): unknown { | ||
| if (typeof obj !== 'object' || obj === null) { | ||
| return obj; | ||
| } | ||
|
|
||
| if (Array.isArray(obj)) { | ||
| return obj.map(redactSubscription); | ||
| } | ||
|
|
||
| const result = { | ||
| ...(obj as Record<string, unknown>), | ||
| } as RedactableSubscription; | ||
|
|
||
| if ('id' in result) { | ||
| result.id = randomHex32(); | ||
| } | ||
| if ('accountNumber' in result) { | ||
| result.accountNumber = randomAccount(); | ||
| } | ||
| if ('subscriptionNumber' in result) { | ||
| result.subscriptionNumber = randomSubscription(); | ||
| } | ||
|
|
||
| if ('ratePlans' in result && Array.isArray(result.ratePlans)) { | ||
| result.ratePlans = result.ratePlans.map((plan) => { | ||
| const newPlan = { ...plan }; | ||
| if ('id' in newPlan) { | ||
| newPlan.id = randomHex32(); | ||
| } | ||
|
|
||
| if ( | ||
| 'ratePlanCharges' in newPlan && | ||
| Array.isArray(newPlan.ratePlanCharges) | ||
| ) { | ||
| newPlan.ratePlanCharges = newPlan.ratePlanCharges.map( | ||
| (charge): RatePlanCharge => ({ | ||
| ...charge, | ||
| id: randomHex32(), | ||
| number: randomChargeNum(), | ||
| }), | ||
| ); | ||
| } | ||
| return newPlan; | ||
| }); | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
|
|
||
| function extractJsonFromLog(entry: string): string | null { | ||
| const lines = entry.split('\n'); | ||
| const startIndex = lines.findIndex((line) => line === '{'); | ||
| if (startIndex === -1) { | ||
| return null; | ||
| } | ||
|
|
||
| const endIndex = lines.findIndex((line, i) => i > startIndex && line === '}'); | ||
| if (endIndex === -1) { | ||
| return null; | ||
| } | ||
|
|
||
| return lines.slice(startIndex, endIndex + 1).join('\n'); | ||
| } | ||
|
|
||
| function fixBareKeys(jsonStr: string): string { | ||
| return jsonStr.replace(/(\s+)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":'); | ||
| } | ||
|
|
||
| async function main() { | ||
| if (!subscriptionsDir.startsWith('/')) { | ||
| console.log( | ||
| 'do not write to a relative path, make sure it is outside of any git roots', | ||
| ); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| if (!existsSync(subscriptionsDir)) { | ||
| mkdirSync(subscriptionsDir, { recursive: false }); | ||
| } | ||
|
|
||
| const region = 'eu-west-1'; | ||
| const queryLookbackDays = 14; | ||
|
|
||
| const client = new CloudWatchLogsClient({ | ||
| region, | ||
| credentials: fromIni({ profile: 'membership' }), | ||
| }); | ||
|
|
||
| const startTime = | ||
| Math.floor(Date.now() / 1000) - queryLookbackDays * 24 * 3600; | ||
| const endTime = Math.floor(Date.now() / 1000); | ||
|
|
||
| const query = `fields @timestamp, @message | ||
| | filter @message like /TRACE HTTP _ZuoraClient EXIT SHORT_ARGS/ | ||
| | filter @message like /path: v1\\/subscriptions\\/A-S[0-9]+/ | ||
| | filter @message like /method: GET/ | ||
| | sort @timestamp desc | ||
| | limit 10000`; | ||
|
|
||
| console.log(`Starting CloudWatch query on group ${logGroupName} ...`); | ||
|
|
||
| const startQueryResp = await client.send( | ||
| new StartQueryCommand({ | ||
| logGroupName, | ||
| startTime, | ||
| endTime, | ||
| queryString: query, | ||
| }), | ||
| ); | ||
|
|
||
| const queryId = startQueryResp.queryId!; | ||
| let status = 'Running'; | ||
| let results: Array<Record<string, string>> = []; | ||
|
|
||
| while (status === 'Running' || status === 'Scheduled') { | ||
| await new Promise((resolve) => setTimeout(resolve, 1000)); | ||
| const resp: GetQueryResultsCommandOutput = await client.send( | ||
| new GetQueryResultsCommand({ queryId }), | ||
| ); | ||
| status = resp.status!; | ||
|
|
||
| if (status === 'Complete' && resp.results) { | ||
| results = resp.results.map((row) => | ||
| Object.fromEntries( | ||
| row.map((field) => [field.field ?? '', field.value ?? '']), | ||
| ), | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| console.log(`Query ${queryId} complete, ${results.length} results`); | ||
|
|
||
| let count = 0; | ||
|
|
||
| for (const fields of results) { | ||
| const message = fields['@message']; | ||
| if (!message) { | ||
| continue; | ||
| } | ||
|
|
||
| console.log('message: ' + message); | ||
|
|
||
| const jsonStr = extractJsonFromLog(message); | ||
| if (!jsonStr) { | ||
| continue; | ||
| } | ||
|
|
||
| console.log(' jsonStr: ' + jsonStr); | ||
|
|
||
| const fixedJson = fixBareKeys(jsonStr); | ||
| console.log(' fixedJson: ' + fixedJson); | ||
|
|
||
| try { | ||
| const parsedJson: unknown = JSON.parse(fixedJson); | ||
| console.log(' parseResult: success'); | ||
|
|
||
| const redacted = redactSubscription(parsedJson); | ||
| const timestamp = fields['@timestamp']!.replace(/ /g, 'T'); | ||
| const fileName = `subscriptionRedacted-${timestamp}.json`; | ||
|
|
||
| const outFile = join(subscriptionsDir, fileName); | ||
| const content = JSON.stringify(redacted, null, 2); | ||
|
|
||
| writeFileSync(outFile, content); | ||
| console.log(' redacted.spaces2: ' + content); | ||
| console.log(` Written ${fileName}`); | ||
| count++; | ||
| } catch (error) { | ||
| console.log(' parseResult: failed', error); | ||
| } | ||
| } | ||
|
|
||
| console.log( | ||
| `Finished! ${count} files written out of potential ${results.length}.`, | ||
| ); | ||
| } | ||
|
|
||
| main().then(console.log).catch(console.error); |
61 changes: 61 additions & 0 deletions
61
handlers/product-switch-api/runManual/realSusbcriptions/testRealSubscriptions.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,61 @@ | ||
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
| import { logger } from '@modules/routing/logger'; | ||
| import type { ZuoraSubscription } from '@modules/zuora/types'; | ||
| import { zuoraSubscriptionSchema } from '@modules/zuora/types'; | ||
| import dayjs from 'dayjs'; | ||
| import zuoraCatalogFixture from '../../../../modules/zuora-catalog/test/fixtures/catalog-prod.json'; | ||
| import { getSinglePlanFlattenedSubscriptionOrThrow } from '../../src/guardianSubscription/getSinglePlanFlattenedSubscriptionOrThrow'; | ||
| import { GuardianSubscriptionParser } from '../../src/guardianSubscription/guardianSubscriptionParser'; | ||
| import { SubscriptionFilter } from '../../src/guardianSubscription/subscriptionFilter'; | ||
| import { productCatalog } from '../../test/productCatalogFixture'; | ||
| import { subscriptionsDir } from './config'; | ||
|
|
||
| test('processes all PROD subscription files successfully', () => { | ||
| const files = fs.readdirSync(subscriptionsDir); | ||
| const matchingFiles = files.filter((file: string) => | ||
| /^subscriptionRedacted-\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\.json$/.test( | ||
| file, | ||
| ), | ||
| ); | ||
|
|
||
| expect(matchingFiles.length).toBeGreaterThan(0); | ||
|
|
||
| const guardianSubscriptionParser = new GuardianSubscriptionParser( | ||
| zuoraCatalogFixture, | ||
| productCatalog, | ||
| ); | ||
|
|
||
| matchingFiles.forEach((file: string) => { | ||
| const filePath = path.join(subscriptionsDir, file); | ||
|
|
||
| try { | ||
| // file names are like subscriptionRedacted-2026-02-03T08:21:22.946.json | ||
| const dateMatch = file.match(/subscriptionRedacted-(\d{4}-\d{2}-\d{2})/); | ||
| if (!dateMatch) { | ||
| throw new Error(`Could not extract date from filename: ${file}`); | ||
| } | ||
| const callDate = dayjs(dateMatch[1]); | ||
|
|
||
| const subscriptionData: ZuoraSubscription = zuoraSubscriptionSchema.parse( | ||
| JSON.parse(fs.readFileSync(filePath, 'utf-8')), | ||
| ); | ||
| const zuoraSubscription = zuoraSubscriptionSchema.parse(subscriptionData); | ||
| const guardianSubscription = | ||
| guardianSubscriptionParser.toGuardianSubscription(zuoraSubscription); | ||
| const filter = | ||
| SubscriptionFilter.activeNonEndedSubscriptionFilter(callDate); | ||
| const filteredSubscription = | ||
| filter.filterSubscription(guardianSubscription); | ||
| const flattenedSubscription = | ||
| getSinglePlanFlattenedSubscriptionOrThrow(filteredSubscription); | ||
|
|
||
| expect(flattenedSubscription).toBeDefined(); | ||
| expect(flattenedSubscription.ratePlan).toBeDefined(); | ||
| logger.log(`Successfully processed ${file}`); | ||
| } catch (error) { | ||
| logger.log(`Failed to process ${file}`, { error }); | ||
| throw error; | ||
| } | ||
| }); | ||
| }); |
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.