Thursday, September 6, 2018

Azure PaaS Sitecore Logs using AzureAILogs.html

When hosting Sitecore XP solutions on Azure PaaS – you’ll quickly find out that accessing and viewing Sitecore logs is different than you may be used to.

Sitecore has a great Knowledgebase article available (Access logs and diagnostics data in Sitecore XP on Azure Web Apps) which describes a couple approaches for obtaining the Sitecore logs on a PaaS environment using both Azure Application Insights or FTP.

Grabbing recent log files is pretty straightforward once you’ve configured FTP credentials to access your Sitecore instance.  Simply FTP into the instance (Filezilla is my favorite), navigate to /LogFiles/Application, sort by date, and download the relevant logs.



My preferred approach, however, is utilizing the AzureAILogs.html file that Sitecore provides within the “Using Azure Application Insights REST API” section of the KB article:


Enabling the API Key also requires a few clicks in the Azure portal, but the instructions for enabling this feature are outlined well – and it only takes a couple of minutes to complete.

If you download and open the HTML file locally, the output is pretty sweet:


Once you input the Sitecore app's App Insights App ID and API key, clicking the 'Update Roles' button retrieves the relevant roles for the application (CM, CD, Reporting, and Processing).

Having obtained the roles, you can select one or multiple roles, configure any specific options (recency // checkbox to only show errors // number of entries), and click the 'Get logs from AI' button to make the call.

You’ll end up with filtered and descending log entries you’d typically get from Sitecore logs:

The bonus here is the 'Download logs' button will download the log files – which are also compatible with Sitecore Log Analyzer.

This is super handy as-is, but I also wanted to make it a little easier to access online.  Specifically:

  1. I don’t want to input in the App ID and API key every time I want to use this tool.
  2. I want to access this from our CM environment, similar to any other admin page.
By adding a few old-fashioned lines of Javascript to my copy of the AzureAILogs.html file (just before the requestHandler function), the page was able to accept two URL query parameters (‘appid’ and ‘apikey’) to prefill the two input values.


For example:
AzureAILogs.html?appid=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx&apikey=0000000000000000000000000000000000000000

In order to access this from our CM Authoring environment, I simply added the HTML page to the /sitecore/admin directory.

Once I was able to hit the page from the server, I tacked on our App ID and API Key to the URL and bookmarked the page.   Voila - simple yet effective.

Of course, this is just one of many ways to view Sitecore logs on a PaaS environment - but I found it to be one of the most useful.

Friday, August 17, 2018

Basic Sitecore Audit Trail with Powershell


Update: This report has been included in the latest version SPE v5.0: 
https://github.com/SitecorePowerShell/Console/issues/1033


Here's a question posted to our internal Sitecore Slack channel yesterday:

The answer was 'no'.

This isn't the first time our clients have requested 'Audit Trail' functionality before, either. And we're talking about the basics here:

  • • When did a user log in?
  • • When did a user log out?
  • • Who published what, and when?
  • • When was this item's workflow executed and by whom?

Unfortunately, there isn't an 'out of the box' solution to easily obtain this data.
The most common "solution" has always been to utilize the Sitecore Log Analyzer's Audit tab - which is great for developers, but not for CMS users.

This tool has saved my sanity so many times. 

Others in the community have shared promising solutions in the past, most of which have become unsupported over time. The Sitecore Audit Trail Marketplace module, for example, was last updated in 2015 and only supports up to version 7.5 (fun fact: mainstream support for this version ended in December 2017!).  Additionally, the setup was heavy - requiring a custom database connection. It also, unfortunately,  had it some known issues associated with it.
While the feature set was fairly extensive, a simpler solution still wasn't available.

The Advanced System Reporter also has an audit feature, but again the last time this module was updated was in 2015 and supports up to version 8.0.

And then it hit me.
POWERSHELL!!!!

You've got to know this by now...my default reaction to any problem is to ask myself: "can this be done using Powershell?".

The idea was to create a Powershell report that consumes the accessible Sitecore log files which already contain the data we need.  The report should include all lines marked as 'AUDIT'' and split into columns.  The user should be able to provide a date range to narrow down the audit.  

Powershell would automatically provide the rest: keyword filtering, sorting, exporting, etc. 

Let's script it.

In order to allow a user to configure a date range, we need to create an interactive dialog

The result:



If the user clicks cancel, we'll want to abort the whole operation.
Otherwise, we'll proceed and create the properties needed for our final ListView.
We'll call a function named Get-Audit where our logic will process.


Within the Get-Audit function, we first obtain the location of the Sitecore log folder (UPDATE: We'll use $SitecoreLogFolder instead. SPE creates a variable $SitecoreLogFolder and resolves the path for you: https://doc.sitecorepowershell.com/working-with-items/variables).  We'll the pull the log files from the resolved log path and filter out everything but the standard log. files:

We'll make sure the user has selected a date range and filter the list of files using those inputs. If a user didn't set a date range, we'll simply use the original file set - which will display the most recent entries. While we're at it, we'll define a 'regex' string used to filter only AUDIT items, and an array object to hold our line objects:


We'll start a loop based on our file count. From there, we can get the contents of the file.


We need a way to include the date for each line object (specifically for our final sorting), so we'll build out a simple date string which we'll use later.


We'll now loop through each line in the file and check if it's marked as 'AUDIT'. If the condition matches, we'll append the simple date string we created previously to the beginning of the line string and sanitize the string by removing double-spaces. In some cases, the audit line will contain ManagedPoolThread #XX instead of an ID. We'll sanitize this as well.

The line now looks like this:
8/17/2018 8324 00:13:47 INFO AUDIT (sitecore\Anonymous): Logout

We'll use the space between each data point to our advantage and split each property into individual objects. The username requires some general sanitation, but more importantly, we'll build out one more DateTime object which we'll use to sort the lines before returning our array. 


Finally, we sort and return our array for the table to process.


Final Script

Putting it all together, our script looks like this:


When the script runs, we get a pretty clear view of who's been doing what:

As always, feel free to use, modify, and build on it as you see fit. This has only been tested on a handful of environments so far, so bugs are still possible.
If you do spot any issues, feel free to report them in the comments - or make the necessary changes and submit a pull request to https://github.com/strezag/sitecore-audit-trail-powershell.

Happy SitecorePowershelling!

Tuesday, July 24, 2018

Custom Sitecore Field: Google Maps Autocomplete

Maps are everywhere.  Most modern web designs often include some kind of map functionality whenever points of interest are involved.  If you've ever built a template that houses location data (stores, events, etc), you'd likely included latitude and longitude data points as Single-Line Text fields on the relevant Sitecore template.

However, this approach still forces the Content Author / Marketer to discover the latitude and longitude points based on an address themselves.

While this isn't too difficult to manage, we wanted to make eliminate this step - and make it easier for the author to ensure they're getting reliable latitude/longitude points every time without the need for any external lat/long discovery.

We've seen other examples (Google Map Location Picker Field or  this older module) exist where maps selectors are used (many of which are pretty old now!), but none that strictly use the Google Maps Autocomplete API.
If you're looking for some code on how to achieve this - you've come to the right place!

GOAL

Create a custom Sitecore Field Type integrated with Google Maps Places Autocomplete API that automatically retrieves and stores Latitude and Longitude points based on an inputted address.

Injecting Scripts to the Content Editor

In order to consume the Google Maps Autocomplete API, our page will need to render a script tag referencing the API in the Content Editor.  Luckily, Sitecore allows us to inject custom styles and scripts to the Content Editor as needed.  In this case, there are two script tags we'll need to add:
    1. The Google Maps Autocomplete API library
    2. A custom Javascript file with methods that initialize, geolocate, and fill in the Latitude and Longitude field

Our patched config entries include a new pipeline:

In our pipeline, we loop through the resource definitions set in the config and process new script tags to be added when the content editor loads:

New Template Field Type Item

Our Autocomplete Address Template Field Type is defined in the Core DB:


The Control field value is set to customfieldtypes:AutocompleteAddress. This means two things:
  1. We'll need to register customfieldtype as a new controlSource
  2. A new AutocompleteAddress class which inherits from the Sitecore.Web.UI.HtmlControls.Control class. 

Template Field Type Code and Config

The config entry to define a new control is pretty straightforward:


The corresponding control code:
In this approach, our Control code actually creates three text boxes on the fly (Address, Latitude, Longitude).  The Address text box control will act as the autocomplete which interacts with the API.

Once an address is selected from the autocomplete selections - the custom JS retrieves the address's latitude and longitude points.  These lat/long values will be mapped to the two additional text boxes, then stored as a NameValueCollection string in the raw value of the Autocomplete field type in the following string format:

Address=2104 North Clark Street, Chicago, IL, USA&Latitude=41.920385&Longitude=-87.637572

To hook the Address textbox to our custom script we're simply adding an onfocus attribute 'javascript:autoCompleteAddress.initAutocomplete' to the Address text box control.

The custom JS handles the rest of the interaction to call the API and populate the Latitude and Longitude textbox controls with the right values.


Utilizing the Field Value

When it's time to use the latitude/longitude points on the front-end, we utilize a StringExtension method to parse the necessary values.

Full source for this implementation can be found here:
https://github.com/strezag/GoogleMapsAutocompleteCustomSitecoreField

While this was built using the latest version of Sitecore (v9 Update-2 at the time of this post), we were able to verify this on a local copy of 8.2.  My guess is that this probably would work for even earlier versions (even if it's just a few tweaks).

Feel free to grab a copy and modify it to your own specs!

Thursday, April 12, 2018

Sitecore 9 Machine Prerequisites Check with PowerShell

Part of setting up Sitecore 9 for the first time requires you to install and confirm the machine's hardware, software, windows features, and SIF prerequisites are all met.

Going through the prerequisite checklist can be a bit daunting when planning to set up Sitecore 9 on an existing workstation (where you may already have some of the prerequisites met) or on a VM/new workstation (where you'll need to install new software) to ensure Sitecore 9 runs smoothly.

To make this process a little easier, I've written a PowerShell script that checks for the prerequisites as defined in the  Sitecore 9 Installation Guide and Sitecore Compatibility Table and reports if you're missing anything. 

As a bonus, it'll apply the IIS_IUSRS Modify permissions to folders defined in section 2.3.1 File System Permissions of the Sitecore 9 Installation Guide wherever applicable, ensure the JAVA_HOME path Environment Variable is set (helps with Solr), and register the Sitecore Powershell Gallery.

The IsThisSitecore9Ready.ps1 script helps verify:

  • - Hardware requirements (are there enough cores and RAM?)
  • - Operating system compatibility (Windows Server 2012 R2 (64-bit) / 2016 (32-bit/64-bit) / Windows 10 (32-bit/64-bit) / Windows 8.1 (32-bit/64-bit)
  • - System Folder Permissions (Sets IIS_IUSRS Modify permissions to folders defined in section 2.3.1 File System Permissions of the Sitecore 9 Installation Guide)
  • - IIS version (8.5+)
  • - NET Framework (4.6.2+).
  • - SQL Server 2014 SP2 or 2016 SP1
  • - JavaRuntime (and confirms JAVA_HOME path Windows EnvironmentVariable is set)
  • - Checks and registers SIF (SitecoreInstallFramework and SitecoreFundamentals)

When you run the script, it's run through a series of checks




Please note that this does not cover the following sections from the Sitecore 9 Installation Guide, so you'll want to ensure these loose ends are wrapped up after:

  • - 2.3.2 Prerequisites for the Sitecore Installation Framework
  • - 2.3.3 Enable Contained Database Authentication 
  • - 2.3.4 Installing Solr

You can grab a copy of the script here: https://github.com/strezag/IsThisSitecore9Ready


As always, feel free to use and modify the script to fit your needs.
Leave a comment if you have any suggestions or recommendations, too!


Good luck!

Friday, March 30, 2018

Quick Tip: xPath Builder Chrome Bookmark

Whenever I need to access Sitecore's xPath Builder, I typically do a Google search to find any random blog post that provides the URL path (no, I don't plan to memorize that URL path any time soon 😊).

However, in order to circumvent this, I now use a Google Chrome Bookmark that uses JavaScript to automatically take me to the xPath Builder.

Simply add this to the content of a new bookmark:

javascript:(rel=>{location=rel.startsWith('/')?`${location.protocol}//${location.host}${rel}`:`${location.protocol}//${location.host}${location.pathname}/${rel}`})('/sitecore/shell/default.aspx?xmlcontrol=IDE.XPath.Builder')
While on any Sitecore site - hit the bookmark and you're there!

Enjoy!

Monday, March 5, 2018

Sitecore Icon Search Web App

While the #SCHackathon was fully underway last weekend (holy cow, just following the Twitter diaries was intense), I was inspired to also stay up all night building something I hope will be useful for Sitecore developers moving forward.

I'll preface this with a quote from Phil Wicklund and Jason Wilkerson's book - Professional Sitecore 8 Development:

"It's often said that the hardest part about Sitecore projects is choosing the right icon."
As a best-practice, Sitecore recommends developers "use icons wherever applicable to facilitate visual differentiation."

But finding the right icon for your Sitecore templates ain't easy.
It unfortunately never has been.

Why?

  1. Opening the Icon selector for the first time takes a while

  2. There are 8,758 icons split across 25 categories - with no out-of-the-box search capabilities  -spotting the right icon can end up being time-consuming.

  3. We've gotten some great community traction in the Marketplace - however, the available modules often compatible with specific versions of Sitecore.  (Kudos to the following module developers - these are not irrelevant).


Introducing: Sitecore Icon Search


Sitecore Icon Search was built to help Sitecore developers quickly identify the right icon for their Sitecore templates - allowing them to focus on creating great visual experiences for Sitecore CMS users.  

There are no package installations or versions to keep up with - simply visit this tool from your web browser, find the best icon, then copy and paste the relative path into the template's Icon field.

demo

This tool isn't currently mapped to any custom domain as it's using a free Azure AppService tier.  Given a positive community response, I'd be happy to upgrade the tier and map a custom one for additional ease of access.

What do you think it should it be?

Feel free to leave your suggestions (or general thoughts) in the comments below!

Happy Sitecoring!



Thursday, February 22, 2018

Real-time, Filterable Trailing/Rolling Sitecore Logs with PowerShell

Sitecore troubleshooting usually goes something like this:

1) Do something on the site
2) Navigate to the /logs directory
3) Sort the files by date
4) Open the latest log file
5) Scroll to the bottom to find the most recent entries
6) Repeat as needed

Sometimes seeing what's being written to the logs in real-time makes a lot more sense during development and troubleshooting sessions.

While there are plenty of note editors out there that support log tailing (Notepad++, SnakeTail, or even just the Command Prompt) there are always caveats (lack of filtering, finding the latest log file) - I wanted a simpler experience, with slightly more advanced options that can be used across any of my local Sitecore instances - without it being too complicated to use.  More specifically, you may not always need all the INFO or WARN messages during a real-time monitoring session.

And since I've become a PowerShell addict throughout the last couple of years...

I ended up with a couple of pretty handy PowerShell script I've been using in my day-to-day development that'd I'd love to share with you.

Actually, there are two scripts!

RollingSitecoreLogs.ps1

The first script can be placed anywhere on your machine.  When you run it, you'll be prompted with a Directory Selection dialog.  Simply navigate to the /logs folder for any Sitecore instance.


Once you've selected the directory, the script prompts you with a second dialog form which allows you to filter in/out INFO, WARN, ERROR entries.


Hit OK, and the log starts rolling in!


RollingSitecoreLogs-RelativeDirectory.ps1

The second script is far less involved and runs from your /logs directory (just drop it right in).


It determines the current directory (based on where the script itself is located), finds the latest log.* file, gives you the option to choose which messages should filter in, and begins a rolling session.

The idea is - since you're probably navigating to your logs directory to pick up on the latest log anyway - once you have this script added to our /logs directory you can simply run the script and have it start a real-time log monitoring session.  Simple as that.

Please note - this only considers the primary log.* files - so it's not built for Crawling.log, Search.log, etc. Perhaps additional options can be added at some point in the future to chose or maybe even merge them somehow.

Feel free to grab whichever script suites your needs here:
https://github.com/strezag/sitecore-rolling-logs-powershell

As always, let me know what you think in the comments.

Happy log-sifting



Monday, January 22, 2018

Keep Performance Counters Enabled After Installing Sitecore 9 Using PowerShell

After figuring out the Sitecore Installation Framework and successfully installing Sitecore 9 - I was finally getting to enjoy that fresh CMS smell.

I decided to take a peek at the logs (which to my surprise were now located inside the Website root's App_Data folder) and was greeted an error:


 ManagedPoolThread #0 22:19:56 WARN Failed to create counter 'Sitecore.System\Events | Events Raised / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #1 22:19:56 WARN Failed to create counter 'Sitecore.System\IO | File Watcher Events / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #2 22:19:56 WARN Failed to create counter 'Sitecore.System\Logging | Errors Logged / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #3 22:19:56 WARN Failed to create counter 'Sitecore.System\Logging | Fatals Logged / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #4 22:19:56 WARN Failed to create counter 'Sitecore.System\Logging | Informations Logged / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #5 22:19:57 WARN Failed to create counter 'Sitecore.System\Logging | Warnings Logged / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #6 22:19:57 WARN Failed to create counter 'Sitecore.System\Logging | Audits Logged / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #7 22:19:57 WARN Failed to create counter 'Sitecore.System\Reflection | Methods Invoked / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #8 22:19:57 WARN Failed to create counter 'Sitecore.System\Reflection | Objects Created / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #9 22:19:57 WARN Failed to create counter 'Sitecore.System\Reflection | Objects Not Created / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #10 22:19:57 WARN Failed to create counter 'Sitecore.System\Reflection | Types Resolved / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #11 22:19:57 WARN Failed to create counter 'Sitecore.System\Reflection | Types Not Resolved / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #12 22:19:58 WARN Failed to create counter 'Sitecore.System\Threading | Background Threads Started / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #13 22:19:58 WARN Failed to create counter 'Sitecore.System\Xml | Packets Created / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 3692 22:19:58 INFO HttpModule is being initialized  
 ManagedPoolThread #13 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Jobs | Jobs Executed / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #12 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Pipelines | Pipelines Aborted / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #11 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Pipelines | Pipelines Executed / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #10 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Pipelines | Processors Executed / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #9 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Publishing | Items Queued / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #8 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Publishing | Replacements / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #7 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Tasks | File Cleanups / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #6 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Tasks | Html Cache Clearings / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #5 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Tasks | Publishings / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #4 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Tasks | Reminders Sent / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  
 ManagedPoolThread #3 22:19:58 WARN Failed to create counter 'Sitecore.Jobs\Tasks | Tasks Executed / sec'. Sitecore has no necessary permissions for reading/creating counters.  
 Message: Access to the registry key 'Global' is denied.  

Given that I was installing Sitecore 9 on a Windows 10 VM, I must have missed a requirement/prerequisite somewhere along the line - but this looked really familiar.

I've seen this before.

Oh yeah...

From https://kb.sitecore.net/articles/404548:
Possible solution:   A Sitecore application pool user has to be a member of the system “Performance Monitor Users” group to have access to the performance counters.
Adding the user to this group and restarting IIS should resolve the problem.

That's right - Windows Performance Counters!


Turns out, this is actually mentioned in the appendix of the Sitecore Experience Platform 9.0 Sitecore Experience Platform Installation Guide under Windows Performance Counters section, too (1 page before the end of the document!):


Sitecore XP contains a built-in functionality that reads and updates the Windows performance counters that you can use to monitor and troubleshoot the Sitecore application. This functionality requires access to Windows registry keys. 

So the obvious two options -

1) Set Counters.Enabled setting to false in \App_Config\Sitecore.config
Booorrring

2) Grant access by making the application pool identity a member of the built-in Performance Monitor Users group.

Instead of simply just disabling the counters, I wanted to see exactly what it takes to keep them on.

This should be as easy as adding the Application Pool Identity of my Sitecore 9 instance as a Performance Monitor Users group.  Unfortunately for me and my VM - the Local Users and Groups option wasn't available for me:
WHERE IS IT?!


Rather than figuring out why Windows 10 isn't showing that option - I figured, why can't this just be automated?

So in the spirit of scripting everything...

I put together the following post-installation script that assigns the environment's AppPoolIdentity account to the Performance Monitor Users group, then resets IIS for the changes to take effect.

All that needs to be configured is the $accountName variable - then simply run the script as in an elevated PowerShell window:



Write-Host 

# Configure this to the same value as the Sitecore Site Name (eg. sc90.local). 
# If you're using Network Service account, set to 'NT AUTHORITY\Network Service'
$accountName = "sc90.local"  
# Get the Performance Monitor Users group policy
$group = [ADSI]"WinNT://$Env:ComputerName/Performance Monitor Users,group"

# Create Account Object
$ntAccount = New-Object System.Security.Principal.NTAccount($accountName)
Write-Host "Account to add to Performance Monitor Users: $ntAccount"

# Translates the account name represented by the NTAccount object into another IdentityReference-derived type.
$strSID = $ntAccount.Translate([System.Security.Principal.SecurityIdentifier])

#Create the user
$user = [ADSI]"WinNT://$strSID"

try
{
        # Set user to group policy
 $group.Add($user.Path)
 Write-Host -ForegroundColor Green "$accountName has been successfully added as a member of the Performance Monitor Users group."
 
 # Reset IIS for changes to take effect
 iisreset
}
catch
{
     Write-Host -ForegroundColor Green "$accountName is already a member of Performance Monitor Users."
}


Write-Host "Process complete."
Write-Host 


After running this script, the logs no longer contained "Access to the registry key 'Global' is denied. / Sitecore has no necessary permissions for reading/creating counters" errors - indicating that Sitecore could now read the permissions.

Feel free to include this in your own set of post-installation steps and modify to fit your needs.

Happy scripting!

Wednesday, January 3, 2018

Sitecore Powershell: Valid Page URLs Report

Preface: I love the Sitecore Powershell Extensions Module, and I opt to use it every chance I get.


One of my clients had a simple request:
"Please provide a list in Excel of every single valid URL on the live global site, please." 

After running ScreamingFrog and obtaining a report with missing URLs (the final list returned was faulty – likely due to the software’s inability to hit specific links only available via AJAX rendered components) - we had a couple options on the table:

  1. Similar to functionality found in a Sitemap component, we need a simple ASPX page that loops through the content that filters out everything but the global English version, generate the URLs, trigger a web request to determine the URL's web status, and display it on the page (or create a Download button to get the list).  Code this, deploy it, etc.
  2. Do all of the above - but with Powershell - which happened to already be installed on the CMS

GUESS which I opted for? :)
Yeah!...you guessed it!

Let's get right into it.

Using the Get-ChildItem command and targetting a specific part of the content tree (explicitly using the Web DB), we get the initial list of English versioned items.
 $itemsWithMatchingCondition = Get-ChildItem 
          -Path web:'/sitecore/content/WebsiteName/Home' 
          -Language 'en' 
          -Version * 
          -Recurse 

With this specific implementation, I was lucky enough to have a stable template naming convention where all items using a template that ended with "Page" were always going to be...well...pages.
(Without this luck, I may have had to check if the item contained at least a main layout within the renderings).

To filter this, we'll use a simple IF statement with a LIKE operator against the initial item list's item:
 iif ($item.Template.Name -like $script:pageString)

Now that we have a list of page items we want to process, we need to generate the item's URL.

This handy function that sets the site context, configures the UrlOptions, and gets the URL via the LinkManager does just that:
function Get-ItemUrl($itemToProcess){
     [Sitecore.Context]::SetActiveSite("website")
     $urlop = New-Object ([Sitecore.Links.UrlOptions]::DefaultOptions)
     $urlop.AddAspxExtension = $false
     $urlop.AlwaysIncludeServerUrl = $true
     $linkUrl = [Sitecore.Links.LinkManager]::GetItemUrl($itemToProcess,$urlop)
     $linkUrl
}

Here's the fun part!

Per the requirement, we'll need to validate that the URLs Sitecore was generating were actually functioning.  Any non-functioning URLs (if any) shouldn't be included in the final report (only status code 200).

Powershell lets us make web requests - which we could then check the status of.
All we need to do here is pass in the URL we generated and expect a true or false value in return:

function IsValidPageStatus($urStr){
    $return = $false;
    $HTTP_Request = [System.Net.WebRequest]::Create($urStr)
    $HTTP_Response = $HTTP_Request.GetResponse()
    $HTTP_Status = [int]$HTTP_Response.StatusCode
    if ($HTTP_Status -eq 200) {
        $return = $true
    }
    else {
        Write-Host $urStr
        Write-Host "Response: " $HTTP_Status
        $return = $false
    }
    $HTTP_Response.Close()
    return $return
}

(Note: Any page URL that fails will be listed in the console after the script completes.)

After every URL goes through this check, we add the item to the array list:

if($isValidUrl){
      $script:itemIDsWithPassedCriteria.Add($item) > $null 
}

Finally, build out the report - which can then be exported via the Powershell ISE in CSV/Excel format:

if ($script:itemIDsWithPassedCriteria.Count -eq 0)
{
    Write-Warning "No page items found."
}else{
$props = @{
 InfoTitle = "Live Page Urls"
 InfoDescription = "Provides a list of all valid page URLs "
 PageSize = 100
}
    $script:itemIDsWithPassedCriteria|Show-ListView @props -Property 
       @{ Label = "Url"; Expression = { Get-ItemUrl ($_) } }
    Close-Window 
}


Here's the full script:

<#
.SYNOPSIS
  Provides a list report of all valid page URLs  
.AUTHOR
Written by Gabe Streza
#>
# Variables
$script:pageString = "* Page" #page string
function GetItemsWhichUsePageTemplate()
{
    $itemsWithMatchingCondition = Get-ChildItem -Path web:'/sitecore/content/WebsiteName/Home' 
                                                        -Language 'en' -Version * -Recurse 
    { 
        if ($item.Template.Name -like $script:pageString)
        {
            $linkUrl = Get-ItemUrl($item)
            $isValidUrl = IsValidPageStatus($linkUrl)
            if($isValidUrl){
                $script:itemIDsWithPassedCriteria.Add($item) > $null # The output of the Add is ignored
            }
        }
    }
}
function Get-ItemUrl($itemToProcess){
     [Sitecore.Context]::SetActiveSite("website")
     $urlop = New-Object ([Sitecore.Links.UrlOptions]::DefaultOptions)
     $urlop.AddAspxExtension = $false
     $urlop.AlwaysIncludeServerUrl = $true
     $linkUrl = [Sitecore.Links.LinkManager]::GetItemUrl($itemToProcess,$urlop)
     $linkUrl
}
function IsValidPageStatus($urStr){
    $return = $false;
    $HTTP_Request = [System.Net.WebRequest]::Create($urStr)
    $HTTP_Response = $HTTP_Request.GetResponse()
    $HTTP_Status = [int]$HTTP_Response.StatusCode
    if ($HTTP_Status -eq 200) {
        $return = $true
    }
    else {
        Write-Host $urStr
        Write-Host "Response: " $HTTP_Status
        $return = $false
    }
    $HTTP_Response.Close()
    return $return
}

$script:itemIDsWithPassedCriteria = New-Object System.Collections.ArrayList
GetItemsWhichUsePageTemplate

if ($script:itemIDsWithPassedCriteria.Count -eq 0)
{
    Write-Warning "No page items found."
}else{
$props = @{
 InfoTitle = "Live Page Urls"
 InfoDescription = "Provides a list of all valid page URLs "
 PageSize = 100
}
    $script:itemIDsWithPassedCriteria|Show-ListView @props 
                                                            -Property @{ Label = "Url"; Expression = { Get-ItemUrl ($_) } }
    Close-Window 
}
Write-Host "Done."

This took about 8 minutes to process a 2000 page site - which is good for a one-time run - but there are certainly some optimizations we should make if this was a report the client would use repeatedly in order to make it a bit snappier.  For this purpose, we're all set!

Feel free to grab this, tinker with it, and make it your own!

Let me know in the comments if this has helped - or if you have any additional recommendations.


Wednesday, December 6, 2017

Sitecore's Jobs.aspx Admin Page for Sitecore v6.6 to v7.1


Up until the release of Sitecore 7.2 Update-6 Sitecore was missing an out-of-the-box Jobs Viewer tool - which resulted in community developers rolling their own and sharing to the rest of us.

Here are links to some of the community-driven implementations:

https://www.geekhive.com/buzz/post/2015/04/sitecore-job-viewer/

https://briancaos.wordpress.com/2014/11/11/sitecore-job-viewer-see-what-sitecore-is-doing-behind-your-back/ 

https://marketplace.sitecore.net/en/Modules/V/View_Sitecore_Jobs.aspx 

https://sitecoreblog.marklowe.ch/2014/06/view-running-sitecore-background-jobs/ 

One feature that separated Sitecore's Jobs.aspx page from the community-driven implementations was its ability to auto-refresh and essentially view jobs running in real-time without the need for a refresh button. 

It also looked very nice! :)

I wanted to use THIS Jobs.aspx page on older versions of Sitecore I still maintained, so I started decompiling the Sitecore.Kernel, SitecoreClient, and Sitecore.ExperienceContentManagement.Administration binaries from v8.1 in an attempt to consolidate all the code into one Jobs.aspx page - no additional dll required.

Here's the result:

  <%@ Page language="c#" EnableEventValidation="false" AutoEventWireup="true" Inherits="Sitecore.sitecore.admin.AdminPage" %>   
  <script runat="server">   
   protected override void OnInit(EventArgs e)   
   {   
   base.CheckSecurity(true); //Required!   
   base.OnInit(e);   
   }   
  void Page_Load(object sender, System.EventArgs e)   
  {   
       Sitecore.Jobs.JobManager.GetJobs();   
     StringBuilder stringBuilder = new StringBuilder();   
     Type type = typeof(Sitecore.Jobs.JobManager);   
     this.ShowRefreshStatus(stringBuilder);   
     System.Reflection.FieldInfo field = type.GetField("_runningJobs", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);   
     if (field != null)   
     {   
      this.ShowJobs(stringBuilder, "Running jobs", ((Sitecore.Collections.SafeDictionary<Sitecore.Handle, Sitecore.Jobs.Job>)field.GetValue(null)).Values.ToArray<Sitecore.Jobs.Job>());   
     }   
     System.Reflection.FieldInfo fieldInfo = type.GetField("_queuedJobs", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);   
     if (fieldInfo != null)   
     {   
      this.ShowJobs(stringBuilder, "Queued jobs", ((Sitecore.Collections.JobCollection)fieldInfo.GetValue(null)).ToArray<Sitecore.Jobs.Job>());   
     }   
     System.Reflection.FieldInfo field1 = type.GetField("_finishedJobs", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);   
     if (field1 != null)   
     {   
      this.ShowJobs(stringBuilder, "Finished jobs", ((Sitecore.Collections.JobCollection)field1.GetValue(null)).Reverse<Sitecore.Jobs.Job>().ToArray<Sitecore.Jobs.Job>());   
     }   
     this.lt.Text = stringBuilder.ToString();   
  }   
  protected virtual void ShowJobs(StringBuilder stringBuilder, string name, ICollection<Sitecore.Jobs.Job> enumerable)   
    {   
     stringBuilder.AppendLine(string.Concat("<h1>", name, ":</h1><br />"));   
     if (enumerable.Count <= 0)   
     {   
      stringBuilder.AppendLine("<b>No jobs</b><br />");   
     }   
     else   
     {   
      stringBuilder.AppendLine("<table class='jobs-table'>");   
      stringBuilder.AppendLine("<thead><tr><td class='counter'>No</td><td class='add-time'>Added</td><td class='title'>Title</td><td class='progress'>Progress</td><td class='priority'>Priority</td></tr></thead>");   
      int num = 1;   
      foreach (Sitecore.Jobs.Job job in enumerable)   
      {   
       long total = job.Status.Total;   
       TimeSpan localTime = TimeSpan.Zero;   
       string str = (localTime.Hours == 0 ? string.Empty : string.Concat(localTime.Hours, "h "));   
       string str1 = (localTime.Minutes == 0 ? string.Empty : string.Concat(localTime.Minutes, "m "));   
       stringBuilder.AppendLine("<tr>");   
       stringBuilder.AppendLine(string.Concat("<td class='counter'>", num, "</td>"));   
       object[] longTimeString = new object[] { "<td class='add-time'>", null, null, null, null, null, null };   
       DateTime dateTime = job.QueueTime.ToLocalTime();   
       longTimeString[1] = dateTime.ToLongTimeString();   
       longTimeString[2] = " (";   
       longTimeString[3] = str;   
       longTimeString[4] = str1;   
       longTimeString[5] = localTime.Seconds;   
       longTimeString[6] = "s ago)</td>";   
       stringBuilder.AppendLine(string.Concat(longTimeString));   
       stringBuilder.AppendLine(string.Concat("<td class='title'>", job.Name, "</td>"));   
       StringBuilder stringBuilder1 = stringBuilder;   
       object[] processed = new object[] { "<td class='progress'>", job.Status.Processed   
              , null, null };   
       processed[2] = (total > (long)0 ? string.Concat(" of ", total) : string.Empty);   
       processed[3] = "</td>";   
       stringBuilder1.AppendLine(string.Concat(processed));   
       stringBuilder.AppendLine(string.Concat("<td class='priority'>", job.Options.Priority, "</td>"));   
       stringBuilder.AppendLine("</tr>");   
       num++;   
      }   
      stringBuilder.AppendLine("</table>");   
     }   
     stringBuilder.AppendLine("<br /><hr />");   
    }   
    protected virtual void ShowRefreshStatus(StringBuilder stringBuilder)   
    {   
     int num;   
     string str;   
     string item = base.Request.QueryString["refresh"];   
     int.TryParse(item, out num);   
     object[] objArray = new object[1];   
     DateTime now = DateTime.Now;   
     objArray[0] = now.ToString(System.Globalization.CultureInfo.InvariantCulture);   
     stringBuilder.Append(Sitecore.StringExtensions.StringExtensions.FormatWith("Last updated: {0}. ", objArray));   
     int[] numArray = new int[] { 1, 2, 5, 10, 20, 30, 60 };   
     stringBuilder.Append(string.Format("Refresh each <a href='jobs.aspx' class='refresh-link {0}'>No Refresh</a>", (num == 0 ? "refresh-selected" : string.Empty)));   
     int[] numArray1 = numArray;   
     for (int i = 0; i < (int)numArray1.Length; i++)   
     {   
      int num1 = numArray1[i];   
      str = (num == num1 ? "refresh-selected" : string.Empty);   
      string str1 = string.Format(", <a href='jobs.aspx?refresh={0}' class='refresh-link {1}'>{0} sec</a>", num1, str);   
      stringBuilder.Append(str1);   
     }   
     stringBuilder.Append("<br /><br />");   
    }   
  </script>    
  <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" >   
  <html xmlns="http://www.w3.org/1999/xhtml">   
   <head runat="server">   
    <title>Jobs Viewer</title>   
    <link rel="Stylesheet" type="text/css" href="/sitecore/shell/themes/standard/default/WebFramework.css" />   
    <link rel="Stylesheet" type="text/css" href="./default.css" />   
    <style type="text/css">   
     .jobs-table {   
      border: solid 1px grey;    
      border-spacing: 2px;   
      border-collapse: separate;   
      width: 100%;   
     }   
     .jobs-table td {   
      padding: 2px;   
     }   
     .jobs-table thead {   
      font-weight: bold;   
     }   
     .jobs-table .counter {   
      width: 25px;   
      text-align: right;   
     }     
     .jobs-table .add-time {   
      width: 150px;   
     }     
     .jobs-table .title {   
      word-break: break-all;   
     }    
     .jobs-table .progress {   
      width: 50px;   
      text-align: center;   
     }    
     .jobs-table .priority {   
      width: 80px;   
     }        
    </style>   
   </head>   
   <body>   
    <form id="Form1" runat="server" class="wf-container">   
     <div class="wf-content">   
      <h1>   
       <a href="/sitecore/admin/">Administration Tools</a> - Jobs Viewer   
      </h1>   
      <br />   
      <asp:Literal runat="server" ID="lt"></asp:Literal>   
      <script type="text/javascript">   
       function getQueryString() {   
        var result = {}, queryString = location.search.substring(1), re = /([^&=]+)=([^&]*)/g, m;   
        while (m = re.exec(queryString)) {   
         result[decodeURIComponent(m[1])] = decodeURIComponent(m[2]);   
        }   
        return result;   
       }   
       var str = getQueryString()["refresh"];   
       if (str != undefined) {   
        c = parseInt(str) * 1000;   
        setTimeout("document.location.href = document.location.href;", c);   
       }   
      </script>   
     </div>   
    </form>   
   </body>   
  </html>   

Here is the Sitecore's Jobs Viewer admin tool working on a clean Sitecore 7.0 site:


Simply copy the code above into a new Jobs.aspx file and drop it into your site's /sitecore/admin folder.

I've confirmed this working on versions as far back as Sitecore versions 6.6.

Enjoy!

Friday, November 17, 2017

Quick Tip: Count Child Items of Sitecore Tree using Google Chrome Dev Console

There have been several requests among different clients our practice works with to provide a simple count of items under a part of a Sitecore tree.

While writing a Sitecore query and running it against the XPath builder is completely feasible, there is a quicker way if you're just trying to get a child count.

Open up the Sitecore Content Editor, activate the node you want to get the number of children under and expand it.

Open up your Google Chrome Dev Console and run the following line of Javascript:

 document.getElementsByClassName('scContentTreeNodeActive')[0].nextSibling.childNodes.length  

This one-liner finds the active Content Tree node and discovers the number of child items living under it.



Note: This doesn't account for all descendants and only seems to work with Google Chrome, however, I've found this useful in a few situations now across any Sitecore version.

Enjoy!


Tuesday, September 5, 2017

Paid Google Site Search is Going Away. What are my options in Sitecore?



I've seen this email a few times now - and as unfortunate as Google's decision is on removing a product that has powered hundreds of thousands of website's site search - this is real.

What does this mean for Sitecore site searches using GSS?

Depending on the original implementation, it could mean the search page stops working altogether - which could occur if your search results have been served via XML.  The XML results will no longer be served, and therefore your site won't have data to build the results page.

Alternatively, if the GSS implementation was a straight script and tag injection, the search page will still work, but you'll be met with 3-5 advertisements on the top results of your search results.

None of these options are ideal, of course.

In addition to one of those actions occurring on your site, you'll also lose any email support from Google, and be left to the mercy of capped query limits.

What are our options?

Luckily, there are a few.  Here are just some you can explore:

Keep what you can (if you can).

If you've confirmed that Google will still serve results and are okay with advertisements (potentially from competitors) slipped onto your website, then do nothing.  Just remember, if your monthly quota is reached, the search pages will stop showing results.

Leverage Sitecore's OOB Lucene Indexes

Sitecore comes with a fairly powerful search engine library and API, making it pretty straight forward to recreate what Google has been serving all built into your solution.  An experienced Sitecore developer should be able to whip up a straight site search based on a template restricted Lucene index in a reasonable timeline.

Odds are that all styles used in the GSS implementation can be reused for the Sitecore Search API implementation.  If you require a more complex solution such as facets / filtering / sorting, etc - this will obviously require more development time - but can be done in phases if the goal is to change over from GSA as soon as possible.  


If you're in the cloud, try Azure Search service

If you use Sitecore Azure PaaS and on Sitecore Experience Platform 8.2 Update-1 or above, you can use Azure Search are your primary search engine. It works similarly to the OOB Lucene implementation (with some exceptions) but is rather a service that lives in the Azure cloud and connected to your solution via a connection string.

Configuration is handled similarly to a Solr implementation and offers "extreme scalability, simplicity, and stability".  The Azure Search service also guarantees 99.95% uptime as a part of the Microsoft Azure service level agreement and can be easily scaled up or down on an as-needed basis.  
There are certainly some limitations with Azure search as of now, but it is worth exploring when choosing a replacement for GSA. 

More information:

Go with Coveo

Coveo offers an extremely powerful search platform that works on top of Sitecore and comes with all the bells and whistles of what you'd expect from a reputable search company.

Coveo features the ability to provide relevant search results, self-learning/self-tuning search, a secure and scalable cloud service, responsive design, and full integrated Sitecore components.   Coveo OOB allows Content Authors to build rich search pages with Sitecore Page Editor, tune search and promote content using Sitecore Rules Engine, and manage search indexes and crawl schedules via Sitecore Index Manager all without additional development resources.

With Coveo Cloud, you don't need to worry about any additional on premise hardware - but you should still be conscious of the additional license costs associated with this option.

If your goal is to take this opportunity to swap in a more elegant search solution - Coveo can help you upgrade the search experience, as opposed to simply replacing it.  

More information:

Searching with Solr

Solr has been a viable option for Sitecore search for years.  Comparable to OOB Lucene, Solr is an enterprise-level Lucene-based search platform making it easier to scale for larger implementation vs the OOB Lucene engine.    Ensuring compatibility for the version of Solr you should be using is critical in your decision here.

There are plenty of Sitecore provided resources and community driven blogs out there to help you get started.

More information:
Sitecore Docs - Walkthrough: Setting up Solr


Other Plug-and-Play Third Party Search Libraries

If development resources are strapped, you can explore an alternative like Bing Search or Cludo which would likely cut on internal development time, but also comes at an additional monthly cost to keep running.   I personally have little experience working with either of these, but both may be feasible in a pinch. 


Do you know of other options not mentioned here?
Feel free to comment below and describe your experience!

Monday, August 28, 2017

Sitecore Text Searcher - The Ultimate SQL Script

"Where can I edit this text in Sitecore?"
"Can you find out where this content can be changed in Sitecore? 
"I tried changing this content in Sitecore here, but it's not reflecting on the front-end after I publish." 

I hear that a lot.  

When maintaining multiple Sitecore solutions - knowing every template, rendering, and how the data ties in to what you see on the front-end - is nearly impossible.   Prior to Helix, developers were free to develop using various patterns. Some are built in MVC, others are WebForms.  Some components rely on datasource items, others rely on data from the items template. Regardless of architecture, we need to be able to help Content Authors change content if they can't find what they're looking to change.

There are typically a few options to obtain the information for the Content Author's request:

     - If you have access to the solution - inspect the page elements, grab a common class or id, do a Entire Solution search, find some files and follow the code to determine how it populates the component.

     - Start with the page item in Sitecore and determine whether the content is located on the item's template, or in within a data source item.

Those options are both fine - and eventually effective - but there is a faster way - IF YOU HAVE ACCESS TO SQL (which is normally the case if you have a recent DB locally or access to the DB server).

Simply start a new query in SQL MS on the Sitecore DB you want to search in (Web or Master),  modify the @SEARCHTERM variable (include the percent symbols eg. '%content goes here%'), and execute this handy script:



DECLARE @SITECOREROOTID NVARCHAR(MAX), @FULLPATHSEARCH NVARCHAR(MAX), @SEARCHTERM NVARCHAR(MAX)

SET @SITECOREROOTID = '11111111-1111-1111-1111-111111111111';
SET @FULLPATHSEARCH = 'sitecore/content/%';
SET @SEARCHTERM = '%search term%';

WITH FullPathItems (ID, NAME, ITEMPATH, TEMPLATEID)
    
AS (SELECT ID,
                
NAME,
                
CAST(NAME AS NVARCHAR(MAX)) AS itempath,
                
TEMPLATEID
        
FROM   [dbo].ITEMS
        
WHERE  ID = @SITECOREROOTID
        
UNION ALL
        
SELECT i.ID,
                
i.NAME,
                
CAST(( ITEMPATH + '/' + i.NAME ) AS NVARCHAR(MAX)) AS itempath,
                
i.TEMPLATEID
        
FROM   [dbo].ITEMS i
                
INNER JOIN FullPathItems a
                        
ON i.PARENTID = a.ID)
                        
SELECT fieldsX.ITEMID,
      
fpi.NAME AS ITEMNAME,
      
fieldsX.VALUE,
      
fpi.TEMPLATEID AS TEMPLATEID,
      
templatesX.NAME AS TEMPLATENAME,
      
ITEMPATH,
      
fieldsX.FIELDID,
      
itemsX.NAME AS FIELDNAME,
      
fieldsX.LANGUAGE  AS ITEMLANGUAGE 

       FROM   [dbo].[FIELDS] fieldsX
      
INNER JOIN FullPathItems fpi
              
ON fpi.ID = fieldsX.ITEMID
      
INNER JOIN [dbo].ITEMS itemsX
              
ON itemsX.ID = fieldsX.FIELDID
      
INNER JOIN [dbo].ITEMS templatesX
              
ON templatesX.ID = fpi.TEMPLATEID

WHERE fieldsX.VALUE LIKE @SEARCHTERM 
AND fpi.ITEMPATH LIKE @FULLPATHSEARCH

/*INCLUDE  THE FOLLOWING LINE TO INCLUDE ITEM ID CONDITION */
-- AND fieldsX.ItemId = 'B3F15B8C-BAE5-40F4-A139-BA7B3EC6E1ED'

/*INCLUDE THE FOLLOWING LINE TO INCLUDE ITEM NAME CONDITION */
-- AND fpi.Name LIKE '%My Item%'

/* INCLUDE THE FOLLOWING LINE TO INCLUDE TEMPLATE NAME CONDITION */
--AND templatesX.Name = 'My Template'

/* INCLUDE THE FOLLOWING LINE TO ADD LANGUAGE CONDITION */
-- AND fieldsX.Language = 'en-US'


ORDER  BY fpi.ITEMPATH



The result includes the following columns:

Sometimes, you'll be presented with several instances of a specific keyword, in which case you can uncomment various lines near the bottom of the script to drill down and filter the results even further.

I find myself using this script on a daily basis and hope that sharing this script helps others track down content quickly and effectively, too.