Skip to main content

C-Metric.com

Call Us +1 (856) 482-7700
Contact Us

Simplifying Google Ads Data Integration with Configuration-Driven Queries

Google Ads Data Integration into a Data Lake looks straightforward when there is only one report.

Build a GAQL query, call the Google Ads API, map the response, and store the data.

The design becomes more complicated when the same integration needs campaign performance, conversions, ad groups, keywords, devices, ads, calls, geographic targeting, and other reporting datasets.

Writing separate API logic for every report would work, but it would also create a lot of repeated code.

Each implementation would need to repeat the same steps:

Authenticate with Google Ads

        ↓

Create GAQL query

        ↓

Apply date filter

        ↓

Execute query for customers

        ↓

Read streaming response

        ↓

Normalize fields

        ↓

Convert field types

        ↓

Store processed data

 

Instead, we designed the Google Ads Data Integration around a shared query configuration.

The core execution logic stays the same. What changes from one report to another is mostly configuration: which Google Ads resource to query, which fields to select, which filters to apply, and how the returned fields should be mapped.

This made it possible to support several Google Ads datasets without maintaining a separate implementation for each one. Artificial Intelligence is transforming businesses by improving efficiency, automating processes, and enabling smarter decision-making. 

The Main Idea: Separate Query Definition from Query Execution

Most Google Ads reports differ in only a few areas.

For example, campaign reporting might query:

SELECT

segments.date,

campaign.id,

campaign.name,

metrics.impressions,

metrics.clicks,

metrics.cost_micros,

metrics.conversions

FROM campaign

WHERE segments.date BETWEEN ‘2026-08-01’ AND ‘2026-08-01’

  AND campaign.status != ‘REMOVED’

 

A device report still uses the campaign resource, but adds a device dimension:

SELECT

segments.date,

campaign.id,

campaign.name,

segments.device,

metrics.impressions,

metrics.clicks,

metrics.cost_micros

FROM campaign

WHERE segments.date BETWEEN ‘2026-08-01’ AND ‘2026-08-01’

  AND campaign.status != ‘REMOVED’

 

A keyword report changes the resource and fields again:

SELECT

segments.date,

campaign.id,

campaign.name,

ad_group.id,

ad_group.name,

ad_group_criterion.keyword.text,

metrics.impressions,

metrics.clicks,

metrics.cost_micros

FROM keyword_view

WHERE segments.date BETWEEN ‘2026-08-01’ AND ‘2026-08-01’

  AND ad_group_criterion.status != ‘REMOVED’

 

The API workflow around these queries is almost identical.

That was the reason for moving the differences into configuration instead of duplicating the processing code.

Building a Common Query Configuration

Each Google Ads dataset is described using a configuration object.

A simplified version looks like this:

type GoogleAdsQueryConfig = {

  sourceId: string;

  resource: string;

  fields: string[];

  where?: string;

 

  columns: {

key: string;

type: string;

googleAdsField?: string;

  }[];

 

  fieldMap: Record<string, string>;

};

 

This configuration contains enough information to build a query and process its response.

For example, campaign reporting can be represented as:

const campaignConfig = {

  sourceId: “campaign_performance”,

 

  resource: “campaign”,

 

  fields: [

“segments.date”,

“segments.ad_network_type”,

“campaign.id”,

“campaign.name”,

“metrics.impressions”,

“metrics.clicks”,

“metrics.ctr”,

“metrics.cost_micros”,

“metrics.conversions”,

“metrics.conversions_value”

  ],

 

  where: “campaign.status != ‘REMOVED'”,

 

  columns: [

{ key: “date”, type: “date” },

{ key: “campaign_id”, type: “id” },

{ key: “campaign_name”, type: “string” },

{ key: “impressions”, type: “integer” },

{ key: “clicks”, type: “integer” },

{ key: “cost”, type: “currency” },

{ key: “conversions”, type: “double” }

  ]

};

 

A device report can use the same structure:

const deviceConfig = {

  sourceId: “device_performance”,

 

  resource: “campaign”,

 

  fields: [

“segments.date”,

    “campaign.id”,

“campaign.name”,

“segments.device”,

“metrics.impressions”,

“metrics.clicks”,

“metrics.cost_micros”

  ],

 

  where: “campaign.status != ‘REMOVED'”,

 

  columns: [

{ key: “date”, type: “date” },

{ key: “campaign_id”, type: “id” },

{ key: “campaign_name”, type: “string” },

{ key: “device”, type: “enum” },

{ key: “impressions”, type: “integer” },

{ key: “clicks”, type: “integer” },

{ key: “cost”, type: “currency” }

  ]

};

 

The important part is that neither configuration contains API execution code.

It only describes what should be requested and how the result should look.

Digital Marketing helps businesses reach the right audience, increase online visibility, generate quality leads, and build stronger customer relationships.

Generating GAQL Dynamically

Once query definitions are stored as configuration, the GAQL statement can be generated by one shared function.

A simplified implementation looks like:

function buildGaqlQuery(

  config: GoogleAdsQueryConfig,

  startDate: string,

  endDate: string

) {

  const whereParts = [

`segments.date BETWEEN ‘${startDate}’ AND ‘${endDate}’`

  ];

 

  if (config.where) {

whereParts.push(config.where);

  }

 

  return `

SELECT ${config.fields.join(“, “)}

FROM ${config.resource}

WHERE ${whereParts.join(” AND “)}

  `;

}

 

Now the same function works for campaigns, devices, keywords, ad groups, conversions, and other datasets.

For example:

const query = buildGaqlQuery(

  campaignConfig,

  “2026-08-01”,

  “2026-08-01”

);

 

The query builder does not need to know that it is creating a campaign query.

It simply understands:

Resource

+

Selected Fields

+

Date Condition

+

Additional Filter

=

GAQL Query

 

This keeps GAQL construction in one place.

If date handling or query formatting needs to change later, the change can be made once instead of across several integrations.

Running Multiple Report Queries Through the Same Structure

The real benefit appears when several report definitions are registered together.

For example:

const googleAdsConfigs = [

  campaignConfig,

  conversionConfig,

  adGroupConfig,

  keywordConfig,

  locationConfig,

  deviceConfig,

  callConfig,

  adConfig

];

 

The common processing layer can accept any one of these configurations:

async function processGoogleAdsQuery(

  config: GoogleAdsQueryConfig,

  startDate: Date,

  endDate: Date

) {

  const query = buildGaqlQuery(

config,

formatDate(startDate),

formatDate(endDate)

  );

 

  const rows = await fetchGoogleAdsRows(query);

 

  return normalizeRows(rows, config);

}

 

Nothing inside this function needs a large condition such as:

if (report === “campaign”) {

  …

} else if (report === “device”) {

  …

} else if (report === “keyword”) {

  …

}

 

The configuration already contains those differences.

The processing flow becomes:

Campaign Config ───────┐

Device Config ─────────┤

Keyword Config ────────┤

Conversion Config ─────┤

Location Config ───────┤

                   

           Common Query Processing

                   

              Google Ads API

                   

             Common Normalization

 

This is much easier to extend than creating another complete integration every time a new report is required.

When One Configuration Needs Multiple GAQL Queries

One interesting case was geographic reporting.

Not every Google Ads Data Integration can always be combined into one GAQL statement in the way we want to process it.

For geographic data, we may need separate queries for dimensions such as:

  •     country,
  •     city,
  •     region,
  •     metro,
  •     postal code.

Writing five completely separate configurations would create duplication because most of the selected fields are identical.

Instead, the configuration can describe query splits.

For example:

const locationConfig = {

  resource: “geographic_view”,

 

  fields: [

“segments.date”,

“campaign.id”,

“campaign.name”,

“geographic_view.country_criterion_id”,

“geographic_view.location_type”,

“segments.geo_target_city”,

“segments.geo_target_region”,

“segments.geo_target_metro”,

    “segments.geo_target_postal_code”,

“metrics.impressions”,

“metrics.clicks”,

“metrics.cost_micros”

  ],

 

  querySplits: [

{ key: “country” },

{

   key: “city”,

   field: “segments.geo_target_city”

},

{

   key: “region”,

   field: “segments.geo_target_region”

},

{

   key: “metro”,

   field: “segments.geo_target_metro”

},

{

   key: “postal_code”,

   field: “segments.geo_target_postal_code”

}

  ]

};

 

The query builder first identifies the fields shared by every query.

It then generates one GAQL statement for each configured split.

A simplified version is:

function buildQueries(config, startDate, endDate) {

  if (!config.querySplits?.length) {

return [{

   query: buildQuery(

     config,

     config.fields,

     startDate,

     endDate

   )

}];

  }

 

  const splitFields = new Set(

config.querySplits

   .map(item => item.field)

   .filter(Boolean)

  );

 

  const commonFields = config.fields.filter(

field => !splitFields.has(field)

  );

 

  return config.querySplits.map(split => ({

key: split.key,

 

query: buildQuery(

   config,

   split.field

     ? […commonFields, split.field]

     : commonFields,

   startDate,

   endDate

)

  }));

}

 

This keeps the geographic definition in one configuration while still allowing several API queries to be generated.

The result is conceptually:

          Geographic Configuration

                    

               Shared Fields

                    

     ┌───────────────┼───────────────┐

        ↓           ↓          

City Query  Region Query Postal Query

        ↓           ↓          

     └───────────────┼───────────────┘

                    

              Common Processing

 

Each response can also carry its query key:

{

  row: apiRow,

  queryKey: “city”

}

 

That makes it possible to identify which geographic level produced the row without creating separate processing logic for every query.

Integration on AWS connects applications, data, and services for seamless and efficient cloud operations. 

One Common API Execution Method

All generated queries eventually use the same Google Ads API function.

The implementation uses the Google Ads searchStream endpoint.

At a simplified level:

async function fetchGoogleAdsRows(

  customerId: string,

  query: string

) {

  const response = await googleAdsRequest(

    `customers/${customerId}/googleAds:searchStream`,

{ query }

  );

 

  const rows = [];

 

  for (const part of response) {

if (part.results) {

   rows.push(…part.results);

}

  }

 

  return rows;

}

 

Whether the query is for campaigns, keywords, devices, or geographic data does not change this API layer.

For a configuration containing several generated queries, the same method is simply called more than once:

const queries = buildQueries(

  config,

  startDate,

  endDate

);

 

const results = [];

 

for (const query of queries) {

  const rows = await fetchGoogleAdsRows(

customerId,

query.query

  );

 

  results.push(

…rows.map(row => ({

   row,

   queryKey: query.key

}))

  );

}

 

This separation keeps API communication independent from report definition.

Common Field Mapping and Type Conversion

Google Ads Data Integration responses also need normalization before being used by the rest of the reporting system.

For example:

Google Ads field      Internal field

—————-      ————–

segments.date         date

campaign.id           campaign_id

campaign.name         campaign_name

metrics.impressions   impressions

metrics.cost_micros   cost

 

That mapping is also stored in configuration.

const fieldMap = {

  date: “date”,

  campaign_id: “campaign_id”,

  campaign_name: “campaign_name”,

  impressions: “impressions”,

  cost: “cost”

};

 

A shared normalization function can then process any configured dataset:

function normalizeRow(flatRow, config) {

  const result = {};

 

  for (const [sourceKey, destinationKey]

of Object.entries(config.fieldMap)) {

 

const column = config.columns.find(

   item => item.key === destinationKey

);

 

result[destinationKey] = convertValue(

   flatRow[sourceKey],

   column?.type

);

  }

 

  return result;

}

 

This gives one place to handle common conversions for:

  •     dates,
  •     strings,
  •     IDs,
  •     integers,
  •     decimal values,
  •     enums,
  •     and currency.

For example, Google Ads cost is returned using cost_micros, so currency normalization can also remain inside the shared processing layer instead of being repeated by every query definition.

Adding a New Google Ads Dataset

With this design, adding another dataset usually does not require another complete API implementation.

The main work is defining:

  1. Google Ads resource
  2. Required GAQL fields
  3. Optional WHERE condition
  4. Output columns
  5. Field types
  6. Source-to-output mapping
  7. Query splits, only when required

 

For example:

const newReportConfig = {

  sourceId: “new_google_ads_report”,

 

  resource: “some_google_ads_resource”,

 

  fields: [

“segments.date”,

“campaign.id”,

“campaign.name”,

“metrics.impressions”

  ],

 

  where: “some.status != ‘REMOVED'”,

 

  columns: [

{ key: “date”, type: “date” },

{ key: “campaign_id”, type: “id” },

{ key: “campaign_name”, type: “string” },

{ key: “impressions”, type: “integer” }

  ],

 

  fieldMap: {

date: “date”,

campaign_id: “campaign_id”,

campaign_name: “campaign_name”,

impressions: “impressions”

  }

};

 

After registering the configuration, the existing query construction, API execution, normalization, and common processing can be reused.

That is much smaller in scope than copying an existing implementation and modifying it for another report.

Why This Structure Worked Well

The biggest advantage was not reducing the number of files. It was reducing the number of places where the same behavior had to be maintained.

The Google Ads Api Integration now has a clear separation:

Configuration

“What data do we need?”

 

        ↓

 

Query Builder

“How should the GAQL query be created?”

 

        ↓

 

API Layer

“How do we retrieve Google Ads data?”

 

        ↓

 

Normalizer

“How should Google fields become internal fields?”

 

        ↓

 

Common Processing

“What do we do with the normalized records?”

 

Each part has one responsibility.

When a new Google Ads query is required, most changes remain in configuration.

When API authentication changes, report configurations do not need to change.

When date-query construction changes, it can be updated centrally.

When a new field type needs normalization, one shared conversion layer can handle it.

And when a report requires several related GAQL queries, query splitting allows those queries to remain part of one logical configuration instead of becoming several independent implementations.

Final Thoughts

Google Ads contains many resources and reporting dimensions, so it is easy for an integration to grow into a collection of similar query scripts.

The approach that worked better for us was to treat GAQL reports as data definitions rather than separate implementations.

A configuration describes the resource, selected fields, filters, expected output, and any required query splits. The shared processing layer handles the rest.

The final pattern is simple:

Multiple Query Configurations

          ↓

Common GAQL Builder

          ↓

Google Ads Search Stream

          ↓

Common Field Normalization

          ↓

Standardized Data

 

This keeps the integration easier to extend while still allowing individual Google Ads reports to have their own fields, filters, resources, and special query requirements.

For integrations that need to support many related API queries, separating what to query from how queries are executed can remove a large amount of duplicated code without making the implementation overly abstract.